mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-27 06:20:02 +00:00
Compare commits
180
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be0cd567ed | ||
|
|
ce7872f667 | ||
|
|
cfa274825f | ||
|
|
e72189adc7 | ||
|
|
7f5ce70f49 | ||
|
|
a2bcd7a302 | ||
|
|
7226fc4400 | ||
|
|
c479e94c49 | ||
|
|
ee5aaec87c | ||
|
|
65ae3f1b45 | ||
|
|
a6149b27c9 | ||
|
|
4b4b418c61 | ||
|
|
8d5cf43139 | ||
|
|
5858eb366d | ||
|
|
be0d3b0156 | ||
|
|
12e8bb5a09 | ||
|
|
a765e76aa3 | ||
|
|
4ef6fc7cc6 | ||
|
|
6b82ea99e2 | ||
|
|
813f3c7bcf | ||
|
|
318c1e100b | ||
|
|
357f14e900 | ||
|
|
99cc2383d6 | ||
|
|
76d71f8344 | ||
|
|
1544c34f44 | ||
|
|
00601678d9 | ||
|
|
700dc53559 | ||
|
|
0faae93049 | ||
|
|
92310ef839 | ||
|
|
5fa6d82b79 | ||
|
|
2078bece40 | ||
|
|
077b48f47f | ||
|
|
7981fa3ce5 | ||
|
|
5c4f7bb521 | ||
|
|
0d16f8e57f | ||
|
|
cf7ebcece1 | ||
|
|
8f8a3b2355 | ||
|
|
ffc409a4ac | ||
|
|
5b5c1975c6 | ||
|
|
25c04c0157 | ||
|
|
1e25c07eac | ||
|
|
ff74a6f811 | ||
|
|
9e07009f1d | ||
|
|
53172c38ad | ||
|
|
658c6b0fde | ||
|
|
b998ff535a | ||
|
|
ba4708f2bb | ||
|
|
bda770650b | ||
|
|
8bccb48c09 | ||
|
|
a9df19cae2 | ||
|
|
4ac49baaff | ||
|
|
38a7e0fdb5 | ||
|
|
7e73e8f599 | ||
|
|
788ac06713 | ||
|
|
622b40f786 | ||
|
|
72eff9b775 | ||
|
|
789644b93f | ||
|
|
f07dfb7a85 | ||
|
|
5bffa6eb04 | ||
|
|
0b6ac19fa7 | ||
|
|
b7e6259a05 | ||
|
|
a0aa62b8c6 | ||
|
|
d80fcedd91 | ||
|
|
41664fc605 | ||
|
|
74df63f717 | ||
|
|
f46041690b | ||
|
|
b8433340c9 | ||
|
|
593c3f732a | ||
|
|
058d527c73 | ||
|
|
15826162b4 | ||
|
|
8d28ad4111 | ||
|
|
bf11d44370 | ||
|
|
f60538343e | ||
|
|
94c179fbd4 | ||
|
|
c7cca23155 | ||
|
|
b1fbede364 | ||
|
|
f55fde17d4 | ||
|
|
96a400b585 | ||
|
|
0cb3417755 | ||
|
|
8cf9e01d90 | ||
|
|
2d5af3b61e | ||
|
|
782a85c26a | ||
|
|
ea516b4a59 | ||
|
|
dd4633ad3a | ||
|
|
82fa69611e | ||
|
|
5cadd9b8c4 | ||
|
|
259f885238 | ||
|
|
54f3595ba7 | ||
|
|
a9f07ce5c6 | ||
|
|
14f99c6df3 | ||
|
|
eefcbc40ef | ||
|
|
7db46a8eb8 | ||
|
|
3a4da2086e | ||
|
|
3e4a2432bc | ||
|
|
76a07242b5 | ||
|
|
aa428c0383 | ||
|
|
c67af47dfe | ||
|
|
539cebaa21 | ||
|
|
5e95248057 | ||
|
|
6fd877ebfc | ||
|
|
4da9661885 | ||
|
|
92e5daa13e | ||
|
|
94c7181aec | ||
|
|
b7626ab23f | ||
|
|
08ffb96680 | ||
|
|
d6226240a0 | ||
|
|
85849693b6 | ||
|
|
319d249445 | ||
|
|
73af3efe12 | ||
|
|
6c7796f12f | ||
|
|
3722602a78 | ||
|
|
7b06cd51ac | ||
|
|
34684da611 | ||
|
|
c20296d106 | ||
|
|
ce1aad8a35 | ||
|
|
e4f20a7f7f | ||
|
|
bcb59d3584 | ||
|
|
7ac5ffdb81 | ||
|
|
6202ae5d06 | ||
|
|
59165df427 | ||
|
|
6bcf90bb79 | ||
|
|
ca85cd31b7 | ||
|
|
8899c9e3f9 | ||
|
|
491382abaa | ||
|
|
b4ee500c14 | ||
|
|
0695a1d707 | ||
|
|
0b2a606a25 | ||
|
|
f3b16b784b | ||
|
|
cb31510b89 | ||
|
|
7a03df5c82 | ||
|
|
adbcba3efc | ||
|
|
0ee0f62b9d | ||
|
|
55e4a70b3b | ||
|
|
53b36fe197 | ||
|
|
195f1862af | ||
|
|
c00dfbddce | ||
|
|
2fef055e5c | ||
|
|
20e10999dd | ||
|
|
2ac9609407 | ||
|
|
f4a06efa21 | ||
|
|
df299bb97d | ||
|
|
2930ca40fb | ||
|
|
0052dfec39 | ||
|
|
e31113aaa2 | ||
|
|
808e133cac | ||
|
|
8d93431319 | ||
|
|
184a04d97f | ||
|
|
08353e3bdd | ||
|
|
9d3597703d | ||
|
|
c4dcb32334 | ||
|
|
cd61b75ff6 | ||
|
|
83c6c66294 | ||
|
|
1614d6a9a7 | ||
|
|
af2167e2f7 | ||
|
|
2d5da0076c | ||
|
|
dcdda0d3c7 | ||
|
|
34f00d86cf | ||
|
|
82f52cd6ec | ||
|
|
14f3a263df | ||
|
|
d59517aeaa | ||
|
|
a35ecca7bd | ||
|
|
066017cdf6 | ||
|
|
8878e9aa5f | ||
|
|
ec1984949b | ||
|
|
f2236a8482 | ||
|
|
1df51b230d | ||
|
|
2f8d3b1d1a | ||
|
|
d0008086ac | ||
|
|
badca54e5d | ||
|
|
ec07f55fd8 | ||
|
|
b47c7ae6aa | ||
|
|
bc618e20ec | ||
|
|
2ce36eb6f1 | ||
|
|
631c486a5a | ||
|
|
8b5c109d55 | ||
|
|
3df1fba518 | ||
|
|
241260a762 | ||
|
|
c706eab60f | ||
|
|
21f8440299 | ||
|
|
8c3e5d547b |
No files matched your search
@@ -49,9 +49,11 @@ body:
|
|||||||
id: mcversion
|
id: mcversion
|
||||||
attributes:
|
attributes:
|
||||||
label: Minecraft Version
|
label: Minecraft Version
|
||||||
description: What version of Minecraft is the server on? Iris 4.x targets 26.2 only; older versions are not supported.
|
description: What version of Minecraft is the server on? Iris 4.x supports 26.1.2 to 26.3 on Bukkit-family servers and 26.3 on Fabric, Forge and NeoForge; older versions are not supported.
|
||||||
options:
|
options:
|
||||||
|
- '26.3'
|
||||||
- '26.2'
|
- '26.2'
|
||||||
|
- '26.1.2'
|
||||||
- Other (unsupported)
|
- Other (unsupported)
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
@@ -59,7 +61,7 @@ body:
|
|||||||
id: loaderversion
|
id: loaderversion
|
||||||
attributes:
|
attributes:
|
||||||
label: Platform / Loader Version
|
label: Platform / Loader Version
|
||||||
description: Exact server or loader build (see console). For example "Paper 26.2-60", "Fabric Loader 0.19.3", "NeoForge 26.2.0.12-beta".
|
description: Exact server or loader build (see console). For example "Paper 26.3-133", "Fabric Loader 0.19.5", "NeoForge 26.3.0.33-beta".
|
||||||
placeholder: DO NOT SAY "LATEST"
|
placeholder: DO NOT SAY "LATEST"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -76,3 +76,8 @@ BUILDS/
|
|||||||
.compatwt/
|
.compatwt/
|
||||||
|
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
|
/VolmLib/
|
||||||
|
/docs/
|
||||||
|
/.perf/
|
||||||
|
/.codegraph/
|
||||||
@@ -4,7 +4,7 @@ Iris is a world generation engine for Minecraft servers and mod loaders. It gene
|
|||||||
biomes, caves, structures, objects, and entities from editable JSON packs, with a full in-game
|
biomes, caves, structures, objects, and entities from editable JSON packs, with a full in-game
|
||||||
studio authoring workflow. The same engine runs as a Bukkit-family plugin and as a Fabric, Forge,
|
studio authoring workflow. The same engine runs as a Bukkit-family plugin and as a Fabric, Forge,
|
||||||
or NeoForge server mod. Cross-platform generation targets deterministic parity for identical
|
or NeoForge server mod. Cross-platform generation targets deterministic parity for identical
|
||||||
artifacts, pack bytes, seeds, and test areas. The master branch targets Minecraft 26.2.
|
artifacts, pack bytes, seeds, and test areas. The master branch targets Minecraft 26.3.
|
||||||
|
|
||||||
# [Support](https://discord.gg/3xxPTpT) **|** [Documentation](https://github.com/VolmitSoftware/docs/blob/master/iris/00-overview.md) **|** [Git](https://github.com/IrisDimensions)
|
# [Support](https://discord.gg/3xxPTpT) **|** [Documentation](https://github.com/VolmitSoftware/docs/blob/master/iris/00-overview.md) **|** [Git](https://github.com/IrisDimensions)
|
||||||
|
|
||||||
@@ -20,12 +20,12 @@ Canonical English is defined in the typed Java catalogs under `core/src/main/jav
|
|||||||
|
|
||||||
| Platform | Artifact | Minecraft | Notes |
|
| Platform | Artifact | Minecraft | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Paper / Purpur / Leaf / Canvas | plugin jar | 26.1.2 - 26.2 | Full feature set |
|
| Paper / Purpur / Leaf / Canvas | plugin jar | 26.1.2 - 26.3 | Full feature set |
|
||||||
| Folia | plugin jar | 26.1.2 - 26.2 | Region-safe scheduling throughout |
|
| Folia | plugin jar | 26.1.2 - 26.3 | Region-safe scheduling throughout |
|
||||||
| Spigot / CraftBukkit | plugin jar | 26.1.2 - 26.2 | Managed `iris:*` creation and generation; exact vanilla-slot `/iris replace` is unavailable |
|
| Spigot / CraftBukkit | plugin jar | 26.1.2 - 26.3 | Managed `iris:*` creation and generation; exact vanilla-slot `/iris replace` is unavailable |
|
||||||
| Fabric | mod jar | 26.2 | Server worldgen + client HUD; requires Fabric Loader 0.19.3+ |
|
| Fabric | mod jar | 26.3 | Server worldgen + client HUD; requires Fabric Loader 0.19.5+ |
|
||||||
| Forge | mod jar | 26.2 | Server worldgen + client HUD; current target is Forge 26.2-65.1.1 |
|
| Forge | mod jar | 26.3 | Server worldgen + client HUD; current target is Forge 26.3-66.0.8 |
|
||||||
| NeoForge | mod jar | 26.2 | Server worldgen + client HUD; current target is NeoForge 26.2.0.59 |
|
| NeoForge | mod jar | 26.3 | Server worldgen + client HUD; current target is NeoForge 26.3.0.33-beta |
|
||||||
|
|
||||||
Java 25 is required on every platform.
|
Java 25 is required on every platform.
|
||||||
|
|
||||||
@@ -271,6 +271,12 @@ when it exists, otherwise `build/consumers/` inside the repo. `-Plocation=/path/
|
|||||||
changes only the consumer directory. Both tasks copy all four verified platform jars into
|
changes only the consumer directory. Both tasks copy all four verified platform jars into
|
||||||
`../PluginOuts/`.
|
`../PluginOuts/`.
|
||||||
|
|
||||||
|
VolmLib is built from source when a `VolmLib` checkout sits beside the repo or in a parent directory
|
||||||
|
(`VOLMLIB_DIR` or `-PlocalVolmLibDirectory=<path>` pick another one). Without one, or with
|
||||||
|
`-PuseLocalVolmLib=false`, the build uses VolmLib `master-SNAPSHOT` from JitPack. There is no VolmLib
|
||||||
|
version to bump. The build logs which VolmLib it used, and every jar records it in
|
||||||
|
`META-INF/volmit/volmlib.properties`.
|
||||||
|
|
||||||
If you need help compiling as a developer or contributor, ask in the Discord.
|
If you need help compiling as a developer or contributor, ask in the Discord.
|
||||||
|
|
||||||
## Adapters / modded development
|
## Adapters / modded development
|
||||||
@@ -298,9 +304,10 @@ properties:
|
|||||||
| `-PirisWorldCheck=<world>` | `iris.worldcheck` | Post-generation world integrity check |
|
| `-PirisWorldCheck=<world>` | `iris.worldcheck` | Post-generation world integrity check |
|
||||||
|
|
||||||
Fabric additionally takes `-PirisClientRunDir=<dir>` to relocate the `runClient` working directory.
|
Fabric additionally takes `-PirisClientRunDir=<dir>` to relocate the `runClient` working directory.
|
||||||
Shared code lives in `adapters/minecraft-common` (all adapters), `adapters/modded-common`
|
Shared code lives in `adapters/modded-common` (loaders + the shared test suite) and
|
||||||
(loaders + the shared test suite), and `adapters/client-common` (client HUD and world-type
|
`adapters/client-common` (client HUD and world-type screens); every adapter adds those source
|
||||||
screens); every adapter adds those source directories, so one edit reaches all three loaders.
|
directories, so one edit reaches all three loaders. Shared Minecraft-native code lives in VolmLib
|
||||||
|
(`native/minecraft-common`).
|
||||||
|
|
||||||
For IDE import you can surface the three adapter builds in the root composite with
|
For IDE import you can surface the three adapter builds in the root composite with
|
||||||
`-PincludeModdedAdapters=true`. It is off by default: each adapter includes the root build back to
|
`-PincludeModdedAdapters=true`. It is off by default: each adapter includes the root build back to
|
||||||
|
|||||||
+36
-31
@@ -4,12 +4,12 @@ import art.arcane.volmlib.nativelib.v26_2_R1.terrain.NativeChunkGenerator;
|
|||||||
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeTerrainColumns;
|
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeTerrainColumns;
|
||||||
import art.arcane.iris.platform.bukkit.nms.BukkitTerrainColumnPolicy;
|
import art.arcane.iris.platform.bukkit.nms.BukkitTerrainColumnPolicy;
|
||||||
|
|
||||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
|
||||||
import art.arcane.iris.generation.runtime.Engine;
|
import art.arcane.iris.generation.runtime.Engine;
|
||||||
import art.arcane.iris.generation.runtime.GenerationSessionLease;
|
import art.arcane.iris.generation.runtime.GenerationSessionLease;
|
||||||
import art.arcane.iris.world.history.BoundaryColumnGeometry;
|
import art.arcane.iris.world.history.BoundaryColumnGeometry;
|
||||||
import art.arcane.iris.world.history.TerrainBoundarySignature;
|
import art.arcane.iris.world.history.TerrainBoundarySignature;
|
||||||
import art.arcane.iris.testsupport.PlatformLeakGuard;
|
import art.arcane.iris.testsupport.PlatformLeakGuard;
|
||||||
|
import art.arcane.volmlib.nativelib.terrain.NativeTerrainColumnPolicy;
|
||||||
import net.minecraft.SharedConstants;
|
import net.minecraft.SharedConstants;
|
||||||
import net.minecraft.server.Bootstrap;
|
import net.minecraft.server.Bootstrap;
|
||||||
import net.minecraft.world.level.LevelHeightAccessor;
|
import net.minecraft.world.level.LevelHeightAccessor;
|
||||||
@@ -24,7 +24,7 @@ import java.util.Optional;
|
|||||||
import java.util.OptionalInt;
|
import java.util.OptionalInt;
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
@@ -32,6 +32,7 @@ import static org.mockito.Mockito.doReturn;
|
|||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
|
||||||
public final class IrisChunkGeneratorHeightCacheTest {
|
public final class IrisChunkGeneratorHeightCacheTest {
|
||||||
@Rule
|
@Rule
|
||||||
@@ -44,68 +45,72 @@ public final class IrisChunkGeneratorHeightCacheTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void repeatedNativeHeightSurvivesResolvedTerrainEvictionAndRuntimeChange() throws Exception {
|
public void repeatedNativeHeightUsesLiveHeightQueriesAcrossRuntimeChanges() throws Exception {
|
||||||
Fixture fixture = fixture();
|
Fixture fixture = fixture();
|
||||||
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
||||||
assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
when(fixture.engine().getHeight(-1, -33, false)).thenReturn(4);
|
||||||
when(fixture.complex().resolvedTerrainColumn(-1, -33))
|
|
||||||
.thenThrow(new IllegalStateException("Full terrain was evicted"));
|
|
||||||
|
|
||||||
for (int index = 0; index < 100; index++) {
|
for (int index = 0; index < 100; index++) {
|
||||||
assertEquals(-11, fixture.generator().getBaseHeight(
|
assertEquals(-11, fixture.generator().getBaseHeight(
|
||||||
-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||||
}
|
}
|
||||||
verify(fixture.complex(), times(1)).resolvedTerrainColumn(-1, -33);
|
verify(fixture.engine(), times(100)).getHeight(-1, -33, false);
|
||||||
|
|
||||||
when(fixture.engine().getCacheID()).thenReturn(2);
|
when(fixture.engine().getCacheID()).thenReturn(2);
|
||||||
doReturn(Optional.of(signature())).when(fixture.complex()).resolvedTerrainColumn(-1, -33);
|
when(fixture.engine().getHeight(-1, -33, false)).thenReturn(7);
|
||||||
assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
assertEquals(-8, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||||
verify(fixture.complex(), times(2)).resolvedTerrainColumn(-1, -33);
|
verify(fixture.engine(), never()).getComplex();
|
||||||
|
verify(fixture.engine(), never()).getMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void ordinaryHeightRemainsLiveUntilResolvedTransitionTerrainExists() throws Exception {
|
public void nativeHeightPredicatesSelectFluidOrSolidHeightWithoutGeneratingTerrain() throws Exception {
|
||||||
Fixture fixture = fixture();
|
Fixture fixture = fixture();
|
||||||
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
||||||
when(fixture.complex().resolvedTerrainColumn(0, 0))
|
|
||||||
.thenReturn(Optional.empty(), Optional.empty(), Optional.of(signature()));
|
|
||||||
when(fixture.engine().getHeight(0, 0, false)).thenReturn(20, 25);
|
when(fixture.engine().getHeight(0, 0, false)).thenReturn(20, 25);
|
||||||
|
when(fixture.engine().getHeight(0, 0, true)).thenReturn(8);
|
||||||
|
|
||||||
assertEquals(5, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
assertEquals(5, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||||
assertEquals(10, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
assertEquals(10, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||||
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
assertEquals(-7, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
|
||||||
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
|
||||||
verify(fixture.engine(), times(2)).getHeight(0, 0, false);
|
verify(fixture.engine(), times(2)).getHeight(0, 0, false);
|
||||||
verify(fixture.complex(), times(3)).resolvedTerrainColumn(0, 0);
|
verify(fixture.engine(), times(1)).getHeight(0, 0, true);
|
||||||
|
verify(fixture.engine(), never()).getComplex();
|
||||||
|
verify(fixture.engine(), never()).getMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void nativePredicatesAndAccessorBoundsRemainExact() throws Exception {
|
public void nativePredicatesAndAccessorBoundsRemainExact() throws Exception {
|
||||||
Fixture fixture = fixture();
|
NativeTerrainColumnPolicy policy = mock(NativeTerrainColumnPolicy.class);
|
||||||
|
NativeTerrainColumnPolicy.ColumnSession session = mock(NativeTerrainColumnPolicy.ColumnSession.class);
|
||||||
|
when(policy.openQuery(any())).thenReturn(session);
|
||||||
|
when(policy.placementKey(anyString())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
doReturn(Optional.of(signature().geometry())).when(session).resolvedColumn();
|
||||||
|
NativeChunkGenerator<?, ?, ?, ?, ?> generator = generator(policy);
|
||||||
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
||||||
for (int repeat = 0; repeat < 2; repeat++) {
|
for (int repeat = 0; repeat < 2; repeat++) {
|
||||||
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
assertEquals(-11, generator.getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||||
assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
|
assertEquals(-13, generator.getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
|
||||||
assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING, bounds, null));
|
assertEquals(-13, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING, bounds, null));
|
||||||
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, bounds, null));
|
assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, bounds, null));
|
||||||
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE,
|
assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE,
|
||||||
LevelHeightAccessor.create(-16, 2), null));
|
LevelHeightAccessor.create(-16, 2), null));
|
||||||
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,
|
assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,
|
||||||
LevelHeightAccessor.create(-14, 3), null));
|
LevelHeightAccessor.create(-14, 3), null));
|
||||||
}
|
}
|
||||||
verify(fixture.complex(), times(6)).resolvedTerrainColumn(0, 0);
|
verify(session, times(6)).resolvedColumn();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Fixture fixture() throws Exception {
|
private static Fixture fixture() throws Exception {
|
||||||
Engine engine = mock(Engine.class);
|
Engine engine = mock(Engine.class);
|
||||||
IrisComplex complex = mock(IrisComplex.class);
|
|
||||||
when(engine.getComplex()).thenReturn(complex);
|
|
||||||
when(engine.getCacheID()).thenReturn(1);
|
when(engine.getCacheID()).thenReturn(1);
|
||||||
when(engine.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop());
|
when(engine.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop());
|
||||||
when(complex.resolvedTerrainColumn(anyInt(), anyInt())).thenReturn(Optional.of(signature()));
|
return new Fixture(generator(new BukkitTerrainColumnPolicy(engine, null)), engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NativeChunkGenerator<?, ?, ?, ?, ?> generator(NativeTerrainColumnPolicy policy) throws Exception {
|
||||||
NativeChunkGenerator<?, ?, ?, ?, ?> generator = mock(NativeChunkGenerator.class, CALLS_REAL_METHODS);
|
NativeChunkGenerator<?, ?, ?, ?, ?> generator = mock(NativeChunkGenerator.class, CALLS_REAL_METHODS);
|
||||||
set(generator, "terrainColumns", new NativeTerrainColumns(new BukkitTerrainColumnPolicy(engine, null)));
|
set(generator, "terrainColumns", new NativeTerrainColumns(policy));
|
||||||
return new Fixture(generator, engine, complex);
|
return generator;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void set(NativeChunkGenerator<?, ?, ?, ?, ?> generator, String name, Object value) throws Exception {
|
private static void set(NativeChunkGenerator<?, ?, ?, ?, ?> generator, String name, Object value) throws Exception {
|
||||||
@@ -127,6 +132,6 @@ public final class IrisChunkGeneratorHeightCacheTest {
|
|||||||
new TerrainBoundarySignature.BiomeEncoding(List.of("minecraft:plains"), new short[]{0})), geometry);
|
new TerrainBoundarySignature.BiomeEncoding(List.of("minecraft:plains"), new short[]{0})), geometry);
|
||||||
}
|
}
|
||||||
|
|
||||||
private record Fixture(NativeChunkGenerator<?, ?, ?, ?, ?> generator, Engine engine, IrisComplex complex) {
|
private record Fixture(NativeChunkGenerator<?, ?, ?, ?, ?> generator, Engine engine) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
package art.arcane.iris.platform.bukkit.nms.v26_2_R1;
|
||||||
|
|
||||||
|
import art.arcane.volmlib.nativelib.v26_2_R1.terrain.NativeTerrainAccessImpl;
|
||||||
|
import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkHolderManager;
|
||||||
|
import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkTaskScheduler;
|
||||||
|
import net.minecraft.SharedConstants;
|
||||||
|
import net.minecraft.server.Bootstrap;
|
||||||
|
import net.minecraft.server.level.ChunkLevel;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.server.level.TicketType;
|
||||||
|
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||||
|
import org.bukkit.craftbukkit.CraftWorld;
|
||||||
|
import org.junit.BeforeClass;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertSame;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
|
import static org.mockito.ArgumentMatchers.same;
|
||||||
|
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
public class NmsChunkRetentionTest {
|
||||||
|
@BeforeClass
|
||||||
|
public static void bootstrapMinecraftRegistries() {
|
||||||
|
SharedConstants.tryDetectVersion();
|
||||||
|
Bootstrap.bootStrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void retentionRequestsNothingBeyondTheOuterRingOfAFullRequest() {
|
||||||
|
NativeTerrainAccessImpl binding = mock(NativeTerrainAccessImpl.class, CALLS_REAL_METHODS);
|
||||||
|
assertEquals(ChunkLevel.RADIUS_AROUND_FULL_CHUNK, binding.fullChunkDependencyRadius());
|
||||||
|
assertEquals(ChunkLevel.MAX_LEVEL, ChunkHolderManager.MAX_TICKET_LEVEL);
|
||||||
|
assertEquals(ChunkLevel.generationStatus(ChunkLevel.FULL_CHUNK_LEVEL + binding.fullChunkDependencyRadius()),
|
||||||
|
ChunkLevel.generationStatus(ChunkHolderManager.MAX_TICKET_LEVEL));
|
||||||
|
assertEquals(ChunkStatus.STRUCTURE_STARTS, ChunkLevel.generationStatus(ChunkHolderManager.MAX_TICKET_LEVEL));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void retainAndReleaseUseOnePermanentNonPersistentTicketAtTheMaximumLevel() throws Exception {
|
||||||
|
NativeTerrainAccessImpl binding = mock(NativeTerrainAccessImpl.class, CALLS_REAL_METHODS);
|
||||||
|
CraftWorld world = mock(CraftWorld.class);
|
||||||
|
ServerLevel level = mock(ServerLevel.class);
|
||||||
|
ChunkTaskScheduler scheduler = mock(ChunkTaskScheduler.class);
|
||||||
|
ChunkHolderManager manager = mock(ChunkHolderManager.class);
|
||||||
|
Field managerField = ChunkTaskScheduler.class.getField("chunkHolderManager");
|
||||||
|
managerField.setAccessible(true);
|
||||||
|
managerField.set(scheduler, manager);
|
||||||
|
when(world.getHandle()).thenReturn(level);
|
||||||
|
when(level.moonrise$getChunkTaskScheduler()).thenReturn(scheduler);
|
||||||
|
when(manager.addTicketAtLevel(any(TicketType.class), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull()))
|
||||||
|
.thenReturn(true);
|
||||||
|
when(manager.removeTicketAtLevel(any(TicketType.class), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull()))
|
||||||
|
.thenReturn(true);
|
||||||
|
|
||||||
|
assertTrue(binding.retainChunk(world, 3, -4));
|
||||||
|
ArgumentCaptor<TicketType> type = ArgumentCaptor.forClass(TicketType.class);
|
||||||
|
verify(manager).addTicketAtLevel(type.capture(), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull());
|
||||||
|
assertEquals(TicketType.NO_TIMEOUT, type.getValue().timeout());
|
||||||
|
assertTrue(type.getValue().doesLoad());
|
||||||
|
assertFalse(type.getValue().doesSimulate());
|
||||||
|
assertFalse(type.getValue().persist());
|
||||||
|
|
||||||
|
assertTrue(binding.releaseChunk(world, 3, -4));
|
||||||
|
verify(manager).removeTicketAtLevel(same(type.getValue()), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull());
|
||||||
|
|
||||||
|
binding.retainChunk(world, 3, -4);
|
||||||
|
ArgumentCaptor<TicketType> again = ArgumentCaptor.forClass(TicketType.class);
|
||||||
|
verify(manager, times(2)).addTicketAtLevel(again.capture(), eq(3), eq(-4),
|
||||||
|
eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull());
|
||||||
|
assertSame(type.getValue(), again.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-2
@@ -15,7 +15,6 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState;
|
|||||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockResolver;
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockResolver;
|
||||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess;
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess;
|
||||||
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeGenerationRegistryImpl;
|
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeGenerationRegistryImpl;
|
||||||
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeWorldGenerationImpl;
|
|
||||||
import art.arcane.volmlib.nativelib.terrain.NativeBiome;
|
import art.arcane.volmlib.nativelib.terrain.NativeBiome;
|
||||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockProperty;
|
import art.arcane.volmlib.nativelib.terrain.NativeBlockProperty;
|
||||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||||
@@ -49,7 +48,7 @@ final class HeadlessNativePlatform implements IrisPlatform, PlatformRegistries,
|
|||||||
return access;
|
return access;
|
||||||
}
|
}
|
||||||
throw new IllegalStateException("Generation registry contract requires loaded native registries");
|
throw new IllegalStateException("Generation registry contract requires loaded native registries");
|
||||||
}), "bukkit-generation-registry-v1", new NativeWorldGenerationImpl().generationRendererIdentity());
|
}), "bukkit-generation-registry-v1");
|
||||||
nativeAccess = new NativeRegistryAccess(new NativeRegistryAccess.Configuration(registries,
|
nativeAccess = new NativeRegistryAccess(new NativeRegistryAccess.Configuration(registries,
|
||||||
() -> { throw new UnsupportedOperationException("Headless reloadable registries are not loaded"); },
|
() -> { throw new UnsupportedOperationException("Headless reloadable registries are not loaded"); },
|
||||||
message -> { throw new IllegalStateException("Unavailable native registry: " + message); }));
|
message -> { throw new IllegalStateException("Unavailable native registry: " + message); }));
|
||||||
|
|||||||
+42
-31
@@ -4,16 +4,17 @@ import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeChunkGenerator;
|
|||||||
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeTerrainColumns;
|
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeTerrainColumns;
|
||||||
import art.arcane.iris.platform.bukkit.nms.BukkitTerrainColumnPolicy;
|
import art.arcane.iris.platform.bukkit.nms.BukkitTerrainColumnPolicy;
|
||||||
|
|
||||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
|
||||||
import art.arcane.iris.generation.runtime.Engine;
|
import art.arcane.iris.generation.runtime.Engine;
|
||||||
import art.arcane.iris.generation.runtime.GenerationSessionLease;
|
import art.arcane.iris.generation.runtime.GenerationSessionLease;
|
||||||
import art.arcane.iris.world.history.BoundaryColumnGeometry;
|
import art.arcane.iris.world.history.BoundaryColumnGeometry;
|
||||||
import art.arcane.iris.world.history.TerrainBoundarySignature;
|
import art.arcane.iris.world.history.TerrainBoundarySignature;
|
||||||
import art.arcane.iris.testsupport.PlatformLeakGuard;
|
import art.arcane.iris.testsupport.PlatformLeakGuard;
|
||||||
|
import art.arcane.volmlib.nativelib.terrain.NativeTerrainColumnPolicy;
|
||||||
import net.minecraft.SharedConstants;
|
import net.minecraft.SharedConstants;
|
||||||
import net.minecraft.server.Bootstrap;
|
import net.minecraft.server.Bootstrap;
|
||||||
import net.minecraft.world.level.LevelHeightAccessor;
|
import net.minecraft.world.level.LevelHeightAccessor;
|
||||||
import net.minecraft.world.level.levelgen.Heightmap;
|
import net.minecraft.world.level.levelgen.Heightmap;
|
||||||
|
import org.junit.AfterClass;
|
||||||
import org.junit.BeforeClass;
|
import org.junit.BeforeClass;
|
||||||
import org.junit.Rule;
|
import org.junit.Rule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
@@ -24,7 +25,7 @@ import java.util.Optional;
|
|||||||
import java.util.OptionalInt;
|
import java.util.OptionalInt;
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
@@ -32,6 +33,7 @@ import static org.mockito.Mockito.doReturn;
|
|||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
|
||||||
public final class IrisChunkGeneratorHeightCacheTest {
|
public final class IrisChunkGeneratorHeightCacheTest {
|
||||||
@Rule
|
@Rule
|
||||||
@@ -44,69 +46,78 @@ public final class IrisChunkGeneratorHeightCacheTest {
|
|||||||
NativeBlockTags.bindHeightmapFixtures();
|
NativeBlockTags.bindHeightmapFixtures();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AfterClass
|
||||||
|
public static void restoreBlockTags() {
|
||||||
|
NativeBlockTags.restoreTags();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void repeatedNativeHeightSurvivesResolvedTerrainEvictionAndRuntimeChange() throws Exception {
|
public void repeatedNativeHeightUsesLiveHeightQueriesAcrossRuntimeChanges() throws Exception {
|
||||||
Fixture fixture = fixture();
|
Fixture fixture = fixture();
|
||||||
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
||||||
assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
when(fixture.engine().getHeight(-1, -33, false)).thenReturn(4);
|
||||||
when(fixture.complex().resolvedTerrainColumn(-1, -33))
|
|
||||||
.thenThrow(new IllegalStateException("Full terrain was evicted"));
|
|
||||||
|
|
||||||
for (int index = 0; index < 100; index++) {
|
for (int index = 0; index < 100; index++) {
|
||||||
assertEquals(-11, fixture.generator().getBaseHeight(
|
assertEquals(-11, fixture.generator().getBaseHeight(
|
||||||
-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||||
}
|
}
|
||||||
verify(fixture.complex(), times(1)).resolvedTerrainColumn(-1, -33);
|
verify(fixture.engine(), times(100)).getHeight(-1, -33, false);
|
||||||
|
|
||||||
when(fixture.engine().getCacheID()).thenReturn(2);
|
when(fixture.engine().getCacheID()).thenReturn(2);
|
||||||
doReturn(Optional.of(signature())).when(fixture.complex()).resolvedTerrainColumn(-1, -33);
|
when(fixture.engine().getHeight(-1, -33, false)).thenReturn(7);
|
||||||
assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
assertEquals(-8, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||||
verify(fixture.complex(), times(2)).resolvedTerrainColumn(-1, -33);
|
verify(fixture.engine(), never()).getComplex();
|
||||||
|
verify(fixture.engine(), never()).getMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void ordinaryHeightRemainsLiveUntilResolvedTransitionTerrainExists() throws Exception {
|
public void nativeHeightPredicatesSelectFluidOrSolidHeightWithoutGeneratingTerrain() throws Exception {
|
||||||
Fixture fixture = fixture();
|
Fixture fixture = fixture();
|
||||||
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
||||||
when(fixture.complex().resolvedTerrainColumn(0, 0))
|
|
||||||
.thenReturn(Optional.empty(), Optional.empty(), Optional.of(signature()));
|
|
||||||
when(fixture.engine().getHeight(0, 0, false)).thenReturn(20, 25);
|
when(fixture.engine().getHeight(0, 0, false)).thenReturn(20, 25);
|
||||||
|
when(fixture.engine().getHeight(0, 0, true)).thenReturn(8);
|
||||||
|
|
||||||
assertEquals(5, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
assertEquals(5, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||||
assertEquals(10, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
assertEquals(10, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||||
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
assertEquals(-7, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
|
||||||
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
|
||||||
verify(fixture.engine(), times(2)).getHeight(0, 0, false);
|
verify(fixture.engine(), times(2)).getHeight(0, 0, false);
|
||||||
verify(fixture.complex(), times(3)).resolvedTerrainColumn(0, 0);
|
verify(fixture.engine(), times(1)).getHeight(0, 0, true);
|
||||||
|
verify(fixture.engine(), never()).getComplex();
|
||||||
|
verify(fixture.engine(), never()).getMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void nativePredicatesAndAccessorBoundsRemainExact() throws Exception {
|
public void nativePredicatesAndAccessorBoundsRemainExact() throws Exception {
|
||||||
Fixture fixture = fixture();
|
NativeTerrainColumnPolicy policy = mock(NativeTerrainColumnPolicy.class);
|
||||||
|
NativeTerrainColumnPolicy.ColumnSession session = mock(NativeTerrainColumnPolicy.ColumnSession.class);
|
||||||
|
when(policy.openQuery(any())).thenReturn(session);
|
||||||
|
when(policy.placementKey(anyString())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
doReturn(Optional.of(signature().geometry())).when(session).resolvedColumn();
|
||||||
|
NativeChunkGenerator<?, ?, ?, ?, ?> generator = generator(policy);
|
||||||
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
||||||
for (int repeat = 0; repeat < 2; repeat++) {
|
for (int repeat = 0; repeat < 2; repeat++) {
|
||||||
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
assertEquals(-11, generator.getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||||
assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
|
assertEquals(-13, generator.getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
|
||||||
assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING, bounds, null));
|
assertEquals(-13, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING, bounds, null));
|
||||||
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, bounds, null));
|
assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, bounds, null));
|
||||||
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE,
|
assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE,
|
||||||
LevelHeightAccessor.create(-16, 2), null));
|
LevelHeightAccessor.create(-16, 2), null));
|
||||||
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,
|
assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,
|
||||||
LevelHeightAccessor.create(-14, 3), null));
|
LevelHeightAccessor.create(-14, 3), null));
|
||||||
}
|
}
|
||||||
verify(fixture.complex(), times(6)).resolvedTerrainColumn(0, 0);
|
verify(session, times(6)).resolvedColumn();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Fixture fixture() throws Exception {
|
private static Fixture fixture() throws Exception {
|
||||||
Engine engine = mock(Engine.class);
|
Engine engine = mock(Engine.class);
|
||||||
IrisComplex complex = mock(IrisComplex.class);
|
|
||||||
when(engine.getComplex()).thenReturn(complex);
|
|
||||||
when(engine.getCacheID()).thenReturn(1);
|
when(engine.getCacheID()).thenReturn(1);
|
||||||
when(engine.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop());
|
when(engine.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop());
|
||||||
when(complex.resolvedTerrainColumn(anyInt(), anyInt())).thenReturn(Optional.of(signature()));
|
return new Fixture(generator(new BukkitTerrainColumnPolicy(engine, null)), engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NativeChunkGenerator<?, ?, ?, ?, ?> generator(NativeTerrainColumnPolicy policy) throws Exception {
|
||||||
NativeChunkGenerator<?, ?, ?, ?, ?> generator = mock(NativeChunkGenerator.class, CALLS_REAL_METHODS);
|
NativeChunkGenerator<?, ?, ?, ?, ?> generator = mock(NativeChunkGenerator.class, CALLS_REAL_METHODS);
|
||||||
set(generator, "terrainColumns", new NativeTerrainColumns(new BukkitTerrainColumnPolicy(engine, null)));
|
set(generator, "terrainColumns", new NativeTerrainColumns(policy));
|
||||||
return new Fixture(generator, engine, complex);
|
return generator;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void set(NativeChunkGenerator<?, ?, ?, ?, ?> generator, String name, Object value) throws Exception {
|
private static void set(NativeChunkGenerator<?, ?, ?, ?, ?> generator, String name, Object value) throws Exception {
|
||||||
@@ -128,6 +139,6 @@ public final class IrisChunkGeneratorHeightCacheTest {
|
|||||||
new TerrainBoundarySignature.BiomeEncoding(List.of("minecraft:plains"), new short[]{0})), geometry);
|
new TerrainBoundarySignature.BiomeEncoding(List.of("minecraft:plains"), new short[]{0})), geometry);
|
||||||
}
|
}
|
||||||
|
|
||||||
private record Fixture(NativeChunkGenerator<?, ?, ?, ?, ?> generator, Engine engine, IrisComplex complex) {
|
private record Fixture(NativeChunkGenerator<?, ?, ?, ?, ?> generator, Engine engine) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+6
@@ -20,6 +20,7 @@ import net.minecraft.world.level.block.Blocks;
|
|||||||
import net.minecraft.world.level.block.RotatedPillarBlock;
|
import net.minecraft.world.level.block.RotatedPillarBlock;
|
||||||
import net.minecraft.world.level.block.LiquidBlock;
|
import net.minecraft.world.level.block.LiquidBlock;
|
||||||
import net.minecraft.world.level.levelgen.Heightmap;
|
import net.minecraft.world.level.levelgen.Heightmap;
|
||||||
|
import org.junit.AfterClass;
|
||||||
import org.junit.BeforeClass;
|
import org.junit.BeforeClass;
|
||||||
import org.junit.Rule;
|
import org.junit.Rule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
@@ -46,6 +47,11 @@ public final class NativeTransitionColumnTest {
|
|||||||
NativeBlockTags.bindHeightmapFixtures();
|
NativeBlockTags.bindHeightmapFixtures();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AfterClass
|
||||||
|
public static void restoreBlockTags() {
|
||||||
|
NativeBlockTags.restoreTags();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void customKeysUseCarrierForNativeColumnsAndHeightmaps() {
|
public void customKeysUseCarrierForNativeColumnsAndHeightmaps() {
|
||||||
String key = "itemsadder:rocks/ruby_ore";
|
String key = "itemsadder:rocks/ruby_ore";
|
||||||
|
|||||||
+17
@@ -1,21 +1,30 @@
|
|||||||
package art.arcane.iris.nativegen.v26_3_R1;
|
package art.arcane.iris.nativegen.v26_3_R1;
|
||||||
|
|
||||||
import net.minecraft.core.Holder;
|
import net.minecraft.core.Holder;
|
||||||
|
import net.minecraft.core.HolderSet;
|
||||||
import net.minecraft.core.registries.BuiltInRegistries;
|
import net.minecraft.core.registries.BuiltInRegistries;
|
||||||
import net.minecraft.core.registries.Registries;
|
import net.minecraft.core.registries.Registries;
|
||||||
import net.minecraft.tags.BlockTags;
|
import net.minecraft.tags.BlockTags;
|
||||||
import net.minecraft.tags.TagLoader;
|
import net.minecraft.tags.TagLoader;
|
||||||
|
import net.minecraft.tags.TagKey;
|
||||||
import net.minecraft.world.level.block.Block;
|
import net.minecraft.world.level.block.Block;
|
||||||
import net.minecraft.world.level.block.Blocks;
|
import net.minecraft.world.level.block.Blocks;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
public final class NativeBlockTags {
|
public final class NativeBlockTags {
|
||||||
|
private static Map<TagKey<Block>, List<Holder<Block>>> originalTags;
|
||||||
|
|
||||||
private NativeBlockTags() {
|
private NativeBlockTags() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void bindHeightmapFixtures() {
|
public static void bindHeightmapFixtures() {
|
||||||
|
originalTags = new HashMap<>();
|
||||||
|
for (HolderSet.Named<Block> tag : BuiltInRegistries.BLOCK.getTags().toList()) {
|
||||||
|
originalTags.put(tag.key(), tag.stream().toList());
|
||||||
|
}
|
||||||
List<Holder<Block>> solid = List.of(Blocks.STONE.builtInRegistryHolder(),
|
List<Holder<Block>> solid = List.of(Blocks.STONE.builtInRegistryHolder(),
|
||||||
Blocks.OAK_LOG.builtInRegistryHolder(), Blocks.SPRUCE_LOG.builtInRegistryHolder());
|
Blocks.OAK_LOG.builtInRegistryHolder(), Blocks.SPRUCE_LOG.builtInRegistryHolder());
|
||||||
List<Holder<Block>> canopy = List.of(Blocks.STONE.builtInRegistryHolder(),
|
List<Holder<Block>> canopy = List.of(Blocks.STONE.builtInRegistryHolder(),
|
||||||
@@ -25,4 +34,12 @@ public final class NativeBlockTags {
|
|||||||
BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP, canopy,
|
BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP, canopy,
|
||||||
BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP_NO_LEAVES, solid))).apply();
|
BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP_NO_LEAVES, solid))).apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void restoreTags() {
|
||||||
|
if (originalTags == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
BuiltInRegistries.BLOCK.prepareTagReload(new TagLoader.LoadResult<>(Registries.BLOCK, originalTags)).apply();
|
||||||
|
originalTags = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+3
@@ -13,6 +13,7 @@ import net.minecraft.data.registries.VanillaRegistries;
|
|||||||
import net.minecraft.resources.RegistryOps;
|
import net.minecraft.resources.RegistryOps;
|
||||||
import net.minecraft.server.Bootstrap;
|
import net.minecraft.server.Bootstrap;
|
||||||
import net.minecraft.world.attribute.EnvironmentAttributes;
|
import net.minecraft.world.attribute.EnvironmentAttributes;
|
||||||
|
import net.minecraft.world.attribute.modifier.MobSpawnSettingsModifier;
|
||||||
import net.minecraft.world.entity.MobCategory;
|
import net.minecraft.world.entity.MobCategory;
|
||||||
import net.minecraft.world.level.biome.Biome;
|
import net.minecraft.world.level.biome.Biome;
|
||||||
import net.minecraft.world.level.biome.MobSpawnSettings;
|
import net.minecraft.world.level.biome.MobSpawnSettings;
|
||||||
@@ -53,5 +54,7 @@ public class CustomBiomeCodecTest {
|
|||||||
assertEquals(2, decoded.count().minInclusive());
|
assertEquals(2, decoded.count().minInclusive());
|
||||||
assertEquals(4, decoded.count().maxInclusive());
|
assertEquals(4, decoded.count().maxInclusive());
|
||||||
assertEquals(0x102030, biome.getWaterColor() & 0xFFFFFF);
|
assertEquals(0x102030, biome.getWaterColor() & 0xFFFFFF);
|
||||||
|
assertEquals(MobSpawnSettingsModifier.overlay(),
|
||||||
|
biome.getAttributes().get(EnvironmentAttributes.NATURAL_MOB_SPAWNS).modifier());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
package art.arcane.iris.platform.bukkit.nms.v26_3_R1;
|
||||||
|
|
||||||
|
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeTerrainAccessImpl;
|
||||||
|
import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkHolderManager;
|
||||||
|
import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkTaskScheduler;
|
||||||
|
import net.minecraft.SharedConstants;
|
||||||
|
import net.minecraft.server.Bootstrap;
|
||||||
|
import net.minecraft.server.level.ChunkLevel;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.server.level.TicketType;
|
||||||
|
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||||
|
import org.bukkit.craftbukkit.CraftWorld;
|
||||||
|
import org.junit.BeforeClass;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertSame;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
|
import static org.mockito.ArgumentMatchers.same;
|
||||||
|
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
public class NmsChunkRetentionTest {
|
||||||
|
@BeforeClass
|
||||||
|
public static void bootstrapMinecraftRegistries() {
|
||||||
|
SharedConstants.tryDetectVersion();
|
||||||
|
Bootstrap.bootStrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void retentionRequestsNothingBeyondTheOuterRingOfAFullRequest() {
|
||||||
|
NativeTerrainAccessImpl binding = mock(NativeTerrainAccessImpl.class, CALLS_REAL_METHODS);
|
||||||
|
assertEquals(ChunkLevel.RADIUS_AROUND_FULL_CHUNK, binding.fullChunkDependencyRadius());
|
||||||
|
assertEquals(ChunkLevel.MAX_LEVEL, ChunkHolderManager.MAX_TICKET_LEVEL);
|
||||||
|
assertEquals(ChunkLevel.generationStatus(ChunkLevel.FULL_CHUNK_LEVEL + binding.fullChunkDependencyRadius()),
|
||||||
|
ChunkLevel.generationStatus(ChunkHolderManager.MAX_TICKET_LEVEL));
|
||||||
|
assertEquals(ChunkStatus.STRUCTURE_STARTS, ChunkLevel.generationStatus(ChunkHolderManager.MAX_TICKET_LEVEL));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void retainAndReleaseUseOnePermanentNonPersistentTicketAtTheMaximumLevel() throws Exception {
|
||||||
|
NativeTerrainAccessImpl binding = mock(NativeTerrainAccessImpl.class, CALLS_REAL_METHODS);
|
||||||
|
CraftWorld world = mock(CraftWorld.class);
|
||||||
|
ServerLevel level = mock(ServerLevel.class);
|
||||||
|
ChunkTaskScheduler scheduler = mock(ChunkTaskScheduler.class);
|
||||||
|
ChunkHolderManager manager = mock(ChunkHolderManager.class);
|
||||||
|
Field managerField = ChunkTaskScheduler.class.getField("chunkHolderManager");
|
||||||
|
managerField.setAccessible(true);
|
||||||
|
managerField.set(scheduler, manager);
|
||||||
|
when(world.getHandle()).thenReturn(level);
|
||||||
|
when(level.moonrise$getChunkTaskScheduler()).thenReturn(scheduler);
|
||||||
|
when(manager.addTicketAtLevel(any(TicketType.class), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull()))
|
||||||
|
.thenReturn(true);
|
||||||
|
when(manager.removeTicketAtLevel(any(TicketType.class), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull()))
|
||||||
|
.thenReturn(true);
|
||||||
|
|
||||||
|
assertTrue(binding.retainChunk(world, 3, -4));
|
||||||
|
ArgumentCaptor<TicketType> type = ArgumentCaptor.forClass(TicketType.class);
|
||||||
|
verify(manager).addTicketAtLevel(type.capture(), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull());
|
||||||
|
assertEquals(TicketType.NO_TIMEOUT, type.getValue().timeout());
|
||||||
|
assertTrue(type.getValue().doesLoad());
|
||||||
|
assertFalse(type.getValue().doesSimulate());
|
||||||
|
assertFalse(type.getValue().persist());
|
||||||
|
|
||||||
|
assertTrue(binding.releaseChunk(world, 3, -4));
|
||||||
|
verify(manager).removeTicketAtLevel(same(type.getValue()), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull());
|
||||||
|
|
||||||
|
binding.retainChunk(world, 3, -4);
|
||||||
|
ArgumentCaptor<TicketType> again = ArgumentCaptor.forClass(TicketType.class);
|
||||||
|
verify(manager, times(2)).addTicketAtLevel(again.capture(), eq(3), eq(-4),
|
||||||
|
eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull());
|
||||||
|
assertSame(type.getValue(), again.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
@@ -34,6 +34,7 @@ import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer;
|
|||||||
import net.minecraft.world.level.levelgen.structure.structures.IglooPieces;
|
import net.minecraft.world.level.levelgen.structure.structures.IglooPieces;
|
||||||
import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece;
|
import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece;
|
||||||
import net.minecraft.world.level.levelgen.structure.structures.SwampHutStructure;
|
import net.minecraft.world.level.levelgen.structure.structures.SwampHutStructure;
|
||||||
|
import org.junit.AfterClass;
|
||||||
import org.junit.BeforeClass;
|
import org.junit.BeforeClass;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
@@ -68,6 +69,11 @@ public class WorldgenTerrainHeightmapsTest {
|
|||||||
NativeBlockTags.bindHeightmapFixtures();
|
NativeBlockTags.bindHeightmapFixtures();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AfterClass
|
||||||
|
public static void restoreBlockTags() {
|
||||||
|
NativeBlockTags.restoreTags();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void authoringHeightmapsReadPlacedObjectsAndFluidsWithoutTerrainResolvers() {
|
public void authoringHeightmapsReadPlacedObjectsAndFluidsWithoutTerrainResolvers() {
|
||||||
ProtoChunk chunk = terrainChunk(new ChunkPos(0, 0));
|
ProtoChunk chunk = terrainChunk(new ChunkPos(0, 0));
|
||||||
|
|||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
package art.arcane.iris.probe;
|
||||||
|
|
||||||
|
import art.arcane.iris.generation.biome.IrisBiomeCustom;
|
||||||
|
import art.arcane.iris.generation.biome.IrisBiomeCustomSpawn;
|
||||||
|
import art.arcane.iris.generation.biome.IrisBiomeCustomSpawnType;
|
||||||
|
import art.arcane.iris.pack.datapack.v263.DataFixerV263;
|
||||||
|
import art.arcane.iris.spi.PlatformGenerationRegistry;
|
||||||
|
import art.arcane.iris.world.history.GenerationRegistryContract;
|
||||||
|
import art.arcane.iris.world.history.GenerationRegistryContractFactory;
|
||||||
|
import art.arcane.volmlib.util.collection.KList;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.junit.AfterClass;
|
||||||
|
import org.junit.BeforeClass;
|
||||||
|
import org.junit.ClassRule;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertNotEquals;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
|
import static org.junit.Assert.assertThrows;
|
||||||
|
|
||||||
|
public final class BareMobSpawnHistoryTest {
|
||||||
|
@ClassRule
|
||||||
|
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||||
|
|
||||||
|
private static final String LEGACY_RENDERER_IDENTITY = "bukkit-v26_3_R1-generated-registry-json-v1";
|
||||||
|
private static final String NATURAL_MOB_SPAWNS = "minecraft:gameplay/natural_mob_spawns";
|
||||||
|
private static final GenerationRegistryContract.PhysicalResourceKey INSTALLED =
|
||||||
|
new GenerationRegistryContract.PhysicalResourceKey(
|
||||||
|
GenerationRegistryContractFactory.BIOME_REGISTRY,
|
||||||
|
"iris:biomes/" + "c".repeat(64)
|
||||||
|
);
|
||||||
|
private static HeadlessNativeRegistries loaded;
|
||||||
|
private static PlatformGenerationRegistry registry;
|
||||||
|
|
||||||
|
@BeforeClass
|
||||||
|
public static void installOverlayRendering() throws IOException {
|
||||||
|
assertNull(Bukkit.getServer());
|
||||||
|
loaded = HeadlessNativeRegistries.load(Map.of(
|
||||||
|
"data/iris/worldgen/biome/biomes/" + "c".repeat(64) + ".json",
|
||||||
|
spawningBiome().generateJson(new DataFixerV263())
|
||||||
|
));
|
||||||
|
registry = new HeadlessNativePlatform(new File("unused-native-platform"), loaded::registries)
|
||||||
|
.generationRegistry();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterClass
|
||||||
|
public static void closeRegistries() throws IOException {
|
||||||
|
if (loaded != null) {
|
||||||
|
loaded.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void bareMobSpawnHistoryValidatesAgainstTheInstalledOverlayRendering() throws IOException {
|
||||||
|
GenerationRegistryContract legacy = legacyContract(INSTALLED, spawningBiome());
|
||||||
|
GenerationRegistryContract.GeneratedSource source = legacy.generatedSources().get(INSTALLED);
|
||||||
|
assertEquals(GenerationRegistryContract.FINGERPRINT_SCHEMA_VERSION_ONE, legacy.fingerprintSchema());
|
||||||
|
assertEquals(LEGACY_RENDERER_IDENTITY, source.rendererIdentity());
|
||||||
|
assertNotEquals(
|
||||||
|
JsonParser.parseString(source.sourceJson()),
|
||||||
|
JsonParser.parseString(registry.generatedDefinition(
|
||||||
|
INSTALLED.registryKey(),
|
||||||
|
INSTALLED.resourceKey()
|
||||||
|
).value())
|
||||||
|
);
|
||||||
|
|
||||||
|
GenerationRegistryContract available = GenerationRegistryContractFactory.captureRequiredDefinitions(
|
||||||
|
Set.of(legacy),
|
||||||
|
new DataFixerV263(),
|
||||||
|
registry
|
||||||
|
);
|
||||||
|
|
||||||
|
legacy.requireDefinitionsAvailableIn(available);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void bareMobSpawnHistoryStillRejectsDifferentSemantics() throws IOException {
|
||||||
|
GenerationRegistryContract changed = legacyContract(INSTALLED, spawningBiome().setTemperature(0.2D));
|
||||||
|
|
||||||
|
IOException failure = assertThrows(IOException.class,
|
||||||
|
() -> GenerationRegistryContractFactory.captureRequiredDefinitions(
|
||||||
|
Set.of(changed),
|
||||||
|
new DataFixerV263(),
|
||||||
|
registry
|
||||||
|
));
|
||||||
|
|
||||||
|
assertEquals("Historical generated registry definition changed for minecraft:worldgen/biome / "
|
||||||
|
+ INSTALLED.resourceKey() + ".", failure.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void bareMobSpawnHistoryStillRejectsAMissingDefinition() throws IOException {
|
||||||
|
GenerationRegistryContract missing = legacyContract(new GenerationRegistryContract.PhysicalResourceKey(
|
||||||
|
GenerationRegistryContractFactory.BIOME_REGISTRY,
|
||||||
|
"iris:biomes/" + "d".repeat(64)
|
||||||
|
), spawningBiome());
|
||||||
|
|
||||||
|
assertThrows(IOException.class, () -> GenerationRegistryContractFactory.captureRequiredDefinitions(
|
||||||
|
Set.of(missing),
|
||||||
|
new DataFixerV263(),
|
||||||
|
registry
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GenerationRegistryContract legacyContract(
|
||||||
|
GenerationRegistryContract.PhysicalResourceKey key,
|
||||||
|
IrisBiomeCustom biome
|
||||||
|
) {
|
||||||
|
JsonObject rendered = JsonParser.parseString(biome.generateJson(new DataFixerV263())).getAsJsonObject();
|
||||||
|
JsonObject attributes = rendered.getAsJsonObject("attributes");
|
||||||
|
attributes.add(NATURAL_MOB_SPAWNS, attributes.getAsJsonObject(NATURAL_MOB_SPAWNS).get("argument"));
|
||||||
|
String captured = registry.canonicalDefinition(key.registryKey(), key.resourceKey(), rendered.toString())
|
||||||
|
.value();
|
||||||
|
String semantic = GenerationRegistryContractFactory.customBiomeEffectiveSemanticJson(biome, null);
|
||||||
|
GenerationRegistryContract.GeneratedSource source = new GenerationRegistryContract.GeneratedSource(
|
||||||
|
GenerationRegistryContractFactory.CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA,
|
||||||
|
GenerationRegistryContractFactory.fingerprintCustomBiomeAuthoredDefinition(semantic),
|
||||||
|
semantic,
|
||||||
|
LEGACY_RENDERER_IDENTITY,
|
||||||
|
GenerationRegistryContractFactory.fingerprintDefinition(
|
||||||
|
key,
|
||||||
|
PlatformGenerationRegistry.Definition.exactJson(captured),
|
||||||
|
"generated"
|
||||||
|
),
|
||||||
|
captured
|
||||||
|
);
|
||||||
|
return GenerationRegistryContract.fromDefinitionsAndGeneratedSources(
|
||||||
|
Map.of(key, GenerationRegistryContractFactory.fingerprintGeneratedSemantic(key, source)),
|
||||||
|
Map.of(key, source)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IrisBiomeCustom spawningBiome() {
|
||||||
|
return new IrisBiomeCustom()
|
||||||
|
.setId("tester")
|
||||||
|
.setSpawnRarity(2)
|
||||||
|
.setSpawns(new KList<>(
|
||||||
|
new IrisBiomeCustomSpawn()
|
||||||
|
.setType("minecraft:zombie")
|
||||||
|
.setGroup(IrisBiomeCustomSpawnType.MONSTER)
|
||||||
|
.setMinCount(2)
|
||||||
|
.setMaxCount(4)
|
||||||
|
.setWeight(7),
|
||||||
|
new IrisBiomeCustomSpawn()
|
||||||
|
.setType("minecraft:cow")
|
||||||
|
.setGroup(IrisBiomeCustomSpawnType.CREATURE)
|
||||||
|
.setMinCount(3)
|
||||||
|
.setMaxCount(3)
|
||||||
|
.setWeight(5)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -29,6 +29,7 @@ import net.minecraft.world.level.levelgen.structure.StructureStart;
|
|||||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
|
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
|
||||||
import net.minecraft.world.level.storage.LevelStorageSource;
|
import net.minecraft.world.level.storage.LevelStorageSource;
|
||||||
import org.junit.Rule;
|
import org.junit.Rule;
|
||||||
|
import org.junit.ClassRule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.junit.rules.TemporaryFolder;
|
import org.junit.rules.TemporaryFolder;
|
||||||
import org.bukkit.craftbukkit.generator.CustomChunkGenerator;
|
import org.bukkit.craftbukkit.generator.CustomChunkGenerator;
|
||||||
@@ -49,6 +50,9 @@ import static org.mockito.Mockito.spy;
|
|||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
public class HeadlessChunkGeneratorTest {
|
public class HeadlessChunkGeneratorTest {
|
||||||
|
@ClassRule
|
||||||
|
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||||
|
|
||||||
@Rule
|
@Rule
|
||||||
public TemporaryFolder temporary = new TemporaryFolder();
|
public TemporaryFolder temporary = new TemporaryFolder();
|
||||||
|
|
||||||
|
|||||||
+4
@@ -31,6 +31,7 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemp
|
|||||||
import net.minecraft.world.level.storage.LevelStorageSource;
|
import net.minecraft.world.level.storage.LevelStorageSource;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.junit.Rule;
|
import org.junit.Rule;
|
||||||
|
import org.junit.ClassRule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.junit.rules.TemporaryFolder;
|
import org.junit.rules.TemporaryFolder;
|
||||||
|
|
||||||
@@ -48,6 +49,9 @@ import static org.junit.Assert.assertThrows;
|
|||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
public final class HeadlessChunkLightingTest {
|
public final class HeadlessChunkLightingTest {
|
||||||
|
@ClassRule
|
||||||
|
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||||
|
|
||||||
@Rule
|
@Rule
|
||||||
public TemporaryFolder temporary = new TemporaryFolder();
|
public TemporaryFolder temporary = new TemporaryFolder();
|
||||||
|
|
||||||
|
|||||||
+4
@@ -38,6 +38,7 @@ import net.minecraft.world.ticks.ScheduledTick;
|
|||||||
import org.bukkit.NamespacedKey;
|
import org.bukkit.NamespacedKey;
|
||||||
import org.bukkit.persistence.PersistentDataType;
|
import org.bukkit.persistence.PersistentDataType;
|
||||||
import org.junit.Rule;
|
import org.junit.Rule;
|
||||||
|
import org.junit.ClassRule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.junit.rules.TemporaryFolder;
|
import org.junit.rules.TemporaryFolder;
|
||||||
|
|
||||||
@@ -54,6 +55,9 @@ import static org.mockito.Mockito.mock;
|
|||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
public class HeadlessChunkSerializationTest {
|
public class HeadlessChunkSerializationTest {
|
||||||
|
@ClassRule
|
||||||
|
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||||
|
|
||||||
@Rule
|
@Rule
|
||||||
public TemporaryFolder temporary = new TemporaryFolder();
|
public TemporaryFolder temporary = new TemporaryFolder();
|
||||||
|
|
||||||
|
|||||||
-3
@@ -1,7 +1,6 @@
|
|||||||
package art.arcane.iris.probe;
|
package art.arcane.iris.probe;
|
||||||
|
|
||||||
import art.arcane.iris.spi.PlatformGenerationRegistry;
|
import art.arcane.iris.spi.PlatformGenerationRegistry;
|
||||||
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeWorldGenerationImpl;
|
|
||||||
import net.minecraft.data.registries.VanillaRegistries;
|
import net.minecraft.data.registries.VanillaRegistries;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
@@ -22,8 +21,6 @@ public final class HeadlessGenerationRegistryTest {
|
|||||||
HeadlessNativeTestRegistries::get);
|
HeadlessNativeTestRegistries::get);
|
||||||
PlatformGenerationRegistry registry = platform.generationRegistry();
|
PlatformGenerationRegistry registry = platform.generationRegistry();
|
||||||
assertEquals("bukkit-generation-registry-v1", registry.runtimeIdentity());
|
assertEquals("bukkit-generation-registry-v1", registry.runtimeIdentity());
|
||||||
assertEquals(new NativeWorldGenerationImpl().generationRendererIdentity(),
|
|
||||||
registry.generatedDefinitionRendererIdentity());
|
|
||||||
assertEquals(registry.canonicalDefinition("minecraft:worldgen/biome", "iris:native_test", biome),
|
assertEquals(registry.canonicalDefinition("minecraft:worldgen/biome", "iris:native_test", biome),
|
||||||
registry.generatedDefinition("minecraft:worldgen/biome", "iris:native_test"));
|
registry.generatedDefinition("minecraft:worldgen/biome", "iris:native_test"));
|
||||||
assertEquals(registry.canonicalDefinition("minecraft:dimension_type", "iris:native_test", dimension),
|
assertEquals(registry.canonicalDefinition("minecraft:dimension_type", "iris:native_test", dimension),
|
||||||
|
|||||||
+4
@@ -9,6 +9,7 @@ import net.minecraft.world.item.Items;
|
|||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import net.minecraft.world.level.block.Blocks;
|
import net.minecraft.world.level.block.Blocks;
|
||||||
|
import org.junit.ClassRule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
|
|
||||||
@@ -18,6 +19,9 @@ import static org.junit.Assert.assertEquals;
|
|||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
public final class HeadlessNativeBootstrapTest {
|
public final class HeadlessNativeBootstrapTest {
|
||||||
|
@ClassRule
|
||||||
|
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void bindsNativeItemComponentsWithoutStartingServer() throws Exception {
|
public void bindsNativeItemComponentsWithoutStartingServer() throws Exception {
|
||||||
HeadlessNativeBootstrap.bindComponents(HeadlessNativeTestRegistries.get());
|
HeadlessNativeBootstrap.bindComponents(HeadlessNativeTestRegistries.get());
|
||||||
|
|||||||
+4
@@ -7,12 +7,16 @@ import net.minecraft.resources.Identifier;
|
|||||||
import net.minecraft.resources.ResourceKey;
|
import net.minecraft.resources.ResourceKey;
|
||||||
import net.minecraft.world.level.biome.Biome;
|
import net.minecraft.world.level.biome.Biome;
|
||||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||||
|
import org.junit.ClassRule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
|
|
||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
public final class HeadlessNativeRegistriesTest {
|
public final class HeadlessNativeRegistriesTest {
|
||||||
|
@ClassRule
|
||||||
|
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void nativeStructureBiomeTagsUseTheLoadedBiomeHolders() throws Exception {
|
public void nativeStructureBiomeTagsUseTheLoadedBiomeHolders() throws Exception {
|
||||||
RegistryAccess registries = HeadlessNativeTestRegistries.get();
|
RegistryAccess registries = HeadlessNativeTestRegistries.get();
|
||||||
|
|||||||
+4
@@ -4,6 +4,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess;
|
|||||||
import net.minecraft.core.registries.Registries;
|
import net.minecraft.core.registries.Registries;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.junit.BeforeClass;
|
import org.junit.BeforeClass;
|
||||||
|
import org.junit.ClassRule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -17,6 +18,9 @@ import static org.junit.Assert.assertFalse;
|
|||||||
import static org.junit.Assert.assertThrows;
|
import static org.junit.Assert.assertThrows;
|
||||||
|
|
||||||
public final class HeadlessNativeRegistryTest {
|
public final class HeadlessNativeRegistryTest {
|
||||||
|
@ClassRule
|
||||||
|
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||||
|
|
||||||
@BeforeClass
|
@BeforeClass
|
||||||
public static void bootstrap() {
|
public static void bootstrap() {
|
||||||
HeadlessNativeBootstrap.initialize();
|
HeadlessNativeBootstrap.initialize();
|
||||||
|
|||||||
+4
@@ -11,6 +11,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeTileReader;
|
|||||||
import net.minecraft.nbt.CompoundTag;
|
import net.minecraft.nbt.CompoundTag;
|
||||||
import net.minecraft.data.registries.VanillaRegistries;
|
import net.minecraft.data.registries.VanillaRegistries;
|
||||||
import org.junit.BeforeClass;
|
import org.junit.BeforeClass;
|
||||||
|
import org.junit.ClassRule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
@@ -23,6 +24,9 @@ import static org.junit.Assert.assertEquals;
|
|||||||
import static org.junit.Assert.assertThrows;
|
import static org.junit.Assert.assertThrows;
|
||||||
|
|
||||||
public final class HeadlessNativeTileTest {
|
public final class HeadlessNativeTileTest {
|
||||||
|
@ClassRule
|
||||||
|
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||||
|
|
||||||
@BeforeClass
|
@BeforeClass
|
||||||
public static void bootstrap() {
|
public static void bootstrap() {
|
||||||
HeadlessNativeBootstrap.initialize();
|
HeadlessNativeBootstrap.initialize();
|
||||||
|
|||||||
+4
@@ -17,6 +17,7 @@ import org.bukkit.Bukkit;
|
|||||||
import org.bukkit.NamespacedKey;
|
import org.bukkit.NamespacedKey;
|
||||||
import org.bukkit.persistence.PersistentDataType;
|
import org.bukkit.persistence.PersistentDataType;
|
||||||
import org.junit.Rule;
|
import org.junit.Rule;
|
||||||
|
import org.junit.ClassRule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.junit.rules.TemporaryFolder;
|
import org.junit.rules.TemporaryFolder;
|
||||||
import org.spigotmc.SpigotWorldConfig;
|
import org.spigotmc.SpigotWorldConfig;
|
||||||
@@ -36,6 +37,9 @@ import static org.junit.Assert.assertSame;
|
|||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
public final class HeadlessRegionTerrainTest {
|
public final class HeadlessRegionTerrainTest {
|
||||||
|
@ClassRule
|
||||||
|
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||||
|
|
||||||
@Rule
|
@Rule
|
||||||
public TemporaryFolder temporary = new TemporaryFolder();
|
public TemporaryFolder temporary = new TemporaryFolder();
|
||||||
|
|
||||||
|
|||||||
+4
@@ -24,6 +24,7 @@ import net.minecraft.world.level.block.state.BlockState;
|
|||||||
import net.minecraft.world.level.chunk.storage.RegionFile;
|
import net.minecraft.world.level.chunk.storage.RegionFile;
|
||||||
import net.minecraft.world.level.chunk.storage.RegionStorageInfo;
|
import net.minecraft.world.level.chunk.storage.RegionStorageInfo;
|
||||||
import org.junit.BeforeClass;
|
import org.junit.BeforeClass;
|
||||||
|
import org.junit.ClassRule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
import java.io.DataInputStream;
|
import java.io.DataInputStream;
|
||||||
@@ -38,6 +39,9 @@ import static org.junit.Assert.assertThrows;
|
|||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
public final class NativeRegionTerrainWriterTest {
|
public final class NativeRegionTerrainWriterTest {
|
||||||
|
@ClassRule
|
||||||
|
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||||
|
|
||||||
@BeforeClass
|
@BeforeClass
|
||||||
public static void bootstrap() {
|
public static void bootstrap() {
|
||||||
SharedConstants.tryDetectVersion();
|
SharedConstants.tryDetectVersion();
|
||||||
|
|||||||
+4
@@ -26,6 +26,7 @@ import net.minecraft.world.level.material.Fluids;
|
|||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.junit.AfterClass;
|
import org.junit.AfterClass;
|
||||||
import org.junit.BeforeClass;
|
import org.junit.BeforeClass;
|
||||||
|
import org.junit.ClassRule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -40,6 +41,9 @@ import static org.junit.Assert.assertThrows;
|
|||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
public final class OfflineNativeFeatureFeasibilityTest {
|
public final class OfflineNativeFeatureFeasibilityTest {
|
||||||
|
@ClassRule
|
||||||
|
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||||
|
|
||||||
private static HeadlessNativeRegistries loaded;
|
private static HeadlessNativeRegistries loaded;
|
||||||
private static RegistryAccess registries;
|
private static RegistryAccess registries;
|
||||||
private static DimensionType dimension;
|
private static DimensionType dimension;
|
||||||
|
|||||||
+4
@@ -1,6 +1,7 @@
|
|||||||
package art.arcane.iris.probe;
|
package art.arcane.iris.probe;
|
||||||
|
|
||||||
import org.junit.Rule;
|
import org.junit.Rule;
|
||||||
|
import org.junit.ClassRule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.junit.rules.TemporaryFolder;
|
import org.junit.rules.TemporaryFolder;
|
||||||
import org.mockito.MockedStatic;
|
import org.mockito.MockedStatic;
|
||||||
@@ -22,6 +23,9 @@ import static org.mockito.Mockito.mock;
|
|||||||
import static org.mockito.Mockito.mockStatic;
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
|
||||||
public final class OfflineRegionPublicationTest {
|
public final class OfflineRegionPublicationTest {
|
||||||
|
@ClassRule
|
||||||
|
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||||
|
|
||||||
@Rule
|
@Rule
|
||||||
public TemporaryFolder temporary = new TemporaryFolder();
|
public TemporaryFolder temporary = new TemporaryFolder();
|
||||||
|
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package art.arcane.iris.probe;
|
||||||
|
|
||||||
|
import io.papermc.paper.registry.PaperRegistryAccess;
|
||||||
|
import org.junit.rules.TestRule;
|
||||||
|
import org.junit.runner.Description;
|
||||||
|
import org.junit.runners.model.Statement;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class PaperRegistryIsolation implements TestRule {
|
||||||
|
@Override
|
||||||
|
public Statement apply(Statement base, Description description) {
|
||||||
|
return new Statement() {
|
||||||
|
@Override
|
||||||
|
public void evaluate() throws Throwable {
|
||||||
|
Map<Object, Object> registries = registries();
|
||||||
|
Map<Object, Object> previous = new HashMap<>(registries);
|
||||||
|
registries.clear();
|
||||||
|
try {
|
||||||
|
base.evaluate();
|
||||||
|
} finally {
|
||||||
|
registries.clear();
|
||||||
|
registries.putAll(previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static Map<Object, Object> registries() throws ReflectiveOperationException {
|
||||||
|
Field field = PaperRegistryAccess.class.getDeclaredField("registries");
|
||||||
|
field.setAccessible(true);
|
||||||
|
return (Map<Object, Object>) field.get(PaperRegistryAccess.instance());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,11 @@ String apiVersion = providers.gradleProperty('apiVersion').get()
|
|||||||
def mainClass = 'art.arcane.iris.Iris'
|
def mainClass = 'art.arcane.iris.Iris'
|
||||||
def bootstrapperClass = 'art.arcane.iris.IrisBootstrap'
|
def bootstrapperClass = 'art.arcane.iris.IrisBootstrap'
|
||||||
|
|
||||||
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
|
|
||||||
.orElse('com.github.VolmitSoftware:VolmLib:master-SNAPSHOT')
|
|
||||||
.get()
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
compileOnly(project(':core'))
|
compileOnly(project(':core'))
|
||||||
compileOnly(libs.lombok)
|
compileOnly(libs.lombok)
|
||||||
annotationProcessor(libs.lombok)
|
annotationProcessor(libs.lombok)
|
||||||
compileOnly(volmLibCoordinate) {
|
compileOnly('com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
|
||||||
transitive = false
|
transitive = false
|
||||||
}
|
}
|
||||||
compileOnly(libs.paper.api)
|
compileOnly(libs.paper.api)
|
||||||
@@ -23,7 +19,7 @@ dependencies {
|
|||||||
testImplementation(libs.bstats)
|
testImplementation(libs.bstats)
|
||||||
testImplementation(project(':core'))
|
testImplementation(project(':core'))
|
||||||
testImplementation(testFixtures(project(':core')))
|
testImplementation(testFixtures(project(':core')))
|
||||||
testImplementation(volmLibCoordinate) {
|
testImplementation('com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
|
||||||
transitive = false
|
transitive = false
|
||||||
}
|
}
|
||||||
compileOnly(libs.placeholderApi)
|
compileOnly(libs.placeholderApi)
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package art.arcane.iris;
|
||||||
|
|
||||||
|
import art.arcane.iris.platform.bootstrap.ServerProperties;
|
||||||
|
import art.arcane.iris.world.IrisStartupValidation;
|
||||||
|
import art.arcane.iris.world.IrisWorldStorage;
|
||||||
|
import art.arcane.iris.world.lifecycle.BukkitWorldConfiguration;
|
||||||
|
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a failed enable from handing Iris worlds to the vanilla generator.
|
||||||
|
* <p>
|
||||||
|
* Paper disables a plugin whose onEnable fails, and CraftServer builds every world bukkit.yml points at a disabled
|
||||||
|
* generator plugin with the vanilla generator. On a server with Iris worlds, Iris therefore stays enabled with its
|
||||||
|
* runtime locked: the generator resolver answers every Iris world with a fail-closed generator, which stops the
|
||||||
|
* level before it exists, and the admission listener refuses logins. It only needs IrisStartupValidation and
|
||||||
|
* logging, never a service the failed enable may not have created.
|
||||||
|
*/
|
||||||
|
final class FailedEnableLock {
|
||||||
|
private FailedEnableLock() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true when Iris must stay enabled and locked, false when the failure may disable Iris as before
|
||||||
|
*/
|
||||||
|
static boolean engage(Throwable failure, boolean irisWorldsPresent, boolean startupWorldsCreated) {
|
||||||
|
if (!irisWorldsPresent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
IrisStartupValidation.markRuntimeInvalid("Iris failed to enable: " + GenerationRefusalNotice.summary(failure));
|
||||||
|
List<String> notice = notice(failure, startupWorldsCreated);
|
||||||
|
try {
|
||||||
|
for (String line : notice) {
|
||||||
|
Iris.error(line);
|
||||||
|
}
|
||||||
|
Iris.reportError(failure);
|
||||||
|
} catch (Throwable loggingFailure) {
|
||||||
|
notice.forEach(System.err::println);
|
||||||
|
failure.addSuppressed(loggingFailure);
|
||||||
|
failure.printStackTrace(System.err);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deferring teardown to the server stop quiesces through the NMS binding and waits for generators Paper closes.
|
||||||
|
* A failed enable may have neither, so it tears down at once even while the server stops.
|
||||||
|
*/
|
||||||
|
static boolean defersTeardownToServerStop(boolean enableFailed, boolean serverStopping) {
|
||||||
|
return !enableFailed && serverStopping;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param startupWorldsCreated true when Iris enabled on a running server (a hotload), whose startup worlds
|
||||||
|
* already exist, so nothing stops
|
||||||
|
*/
|
||||||
|
static List<String> notice(Throwable failure, boolean startupWorldsCreated) {
|
||||||
|
return GenerationRefusalNotice.compose(
|
||||||
|
"Iris failed to enable",
|
||||||
|
GenerationRefusalNotice.causes(failure),
|
||||||
|
List.of(
|
||||||
|
"Iris stays enabled with a locked runtime so no Iris world falls back to vanilla generation.",
|
||||||
|
startupWorldsCreated
|
||||||
|
? "Every Iris world that is not loaded yet refuses to load; the server keeps running"
|
||||||
|
+ " and no chunks are written for them."
|
||||||
|
: "Every Iris world refuses to generate: server startup stops before the first one"
|
||||||
|
+ " loads, and no chunks are written.",
|
||||||
|
"Player logins are refused until this is fixed and the server restarts."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean irisWorldsPresent() {
|
||||||
|
try {
|
||||||
|
return irisWorldsPresent(IrisWorldStorage.levelRoot(), ServerProperties.BUKKIT_YML);
|
||||||
|
} catch (Throwable unreadable) {
|
||||||
|
Iris.reportError("Could not inspect this server for Iris worlds; treating it as having them.", unreadable);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean irisWorldsPresent(File levelRoot, File bukkitConfiguration) throws IOException {
|
||||||
|
return IrisWorldStorage.hasManagedWorldStorage(levelRoot)
|
||||||
|
|| BukkitWorldConfiguration.configuresLoadingIrisWorld(bukkitConfiguration, levelRoot.toPath());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
package art.arcane.iris;
|
package art.arcane.iris;
|
||||||
|
|
||||||
|
import java.util.concurrent.CompletionStage;
|
||||||
import art.arcane.volmlib.util.diagnostics.BukkitDebugDump;
|
import art.arcane.volmlib.util.diagnostics.BukkitDebugDump;
|
||||||
import art.arcane.iris.generation.runtime.IrisEngineEffects;
|
import art.arcane.iris.generation.runtime.IrisEngineEffects;
|
||||||
import art.arcane.iris.generation.runtime.IrisWorldManager;
|
import art.arcane.iris.generation.runtime.IrisWorldManager;
|
||||||
@@ -32,9 +33,9 @@ import art.arcane.iris.world.IrisStartupValidation;
|
|||||||
import art.arcane.iris.world.IrisStartupAdmissionListener;
|
import art.arcane.iris.world.IrisStartupAdmissionListener;
|
||||||
import art.arcane.iris.world.BukkitWorldReconciler;
|
import art.arcane.iris.world.BukkitWorldReconciler;
|
||||||
import art.arcane.iris.world.IrisWorldGeneratorResolver;
|
import art.arcane.iris.world.IrisWorldGeneratorResolver;
|
||||||
import art.arcane.iris.world.IrisWorldStorage;
|
|
||||||
import art.arcane.iris.world.PendingWorldDeleteQueue;
|
import art.arcane.iris.world.PendingWorldDeleteQueue;
|
||||||
import art.arcane.iris.world.PendingWorldReplacementManager;
|
import art.arcane.iris.world.PendingWorldReplacementManager;
|
||||||
|
import art.arcane.iris.world.WorldRefusalReporter;
|
||||||
import art.arcane.iris.configuration.SettingsHotloadWatch;
|
import art.arcane.iris.configuration.SettingsHotloadWatch;
|
||||||
import art.arcane.iris.pack.datapack.ServerConfigurator;
|
import art.arcane.iris.pack.datapack.ServerConfigurator;
|
||||||
import art.arcane.iris.pack.datapack.DatapackIngestService;
|
import art.arcane.iris.pack.datapack.DatapackIngestService;
|
||||||
@@ -200,6 +201,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false);
|
private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false);
|
||||||
private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false);
|
private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false);
|
||||||
private volatile boolean generatorDrainCompleted;
|
private volatile boolean generatorDrainCompleted;
|
||||||
|
private volatile boolean enableFailed;
|
||||||
private volatile PlaceholderRegistration papiRegistration;
|
private volatile PlaceholderRegistration papiRegistration;
|
||||||
private volatile IrisPapiListener papiListener;
|
private volatile IrisPapiListener papiListener;
|
||||||
private volatile IrisPapiState papiState;
|
private volatile IrisPapiState papiState;
|
||||||
@@ -613,21 +615,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
return plugin == null ? ComponentLog.discriminator("Iris", "&a") : plugin.getTag();
|
return plugin == null ? ComponentLog.discriminator("Iris", "&a") : plugin.getTag();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private void enable() {
|
||||||
* @return false when the bootstrap was aborted (unsupported server version); the caller
|
|
||||||
* must bail out of onEnable without touching any further setup.
|
|
||||||
*/
|
|
||||||
private boolean enable() {
|
|
||||||
if (!INMS.isBound()) {
|
|
||||||
Throwable bindFailure = INMS.bindFailure();
|
|
||||||
Iris.error("Iris cannot start: " + (bindFailure == null
|
|
||||||
? "no NMS binding is available for this server version."
|
|
||||||
: bindFailure.getMessage()));
|
|
||||||
// Deferred one tick: disablePlugin from inside onEnable re-enters onDisable
|
|
||||||
// synchronously and the loader then continues registering the half-enabled plugin.
|
|
||||||
J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
EnableTimings timings = new EnableTimings();
|
EnableTimings timings = new EnableTimings();
|
||||||
alreadyDrained.set(false);
|
alreadyDrained.set(false);
|
||||||
postStopFinisherStarted.set(false);
|
postStopFinisherStarted.set(false);
|
||||||
@@ -788,7 +776,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
});
|
});
|
||||||
timings.mark("startupTasks");
|
timings.mark("startupTasks");
|
||||||
timings.report();
|
timings.report();
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class EnableTimings {
|
private static final class EnableTimings {
|
||||||
@@ -910,20 +897,25 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
IrisPlatforms.bind(new BukkitPlatform());
|
enableFailed = false;
|
||||||
IrisStartupValidation.begin();
|
IrisStartupValidation.begin();
|
||||||
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
|
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
|
||||||
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
|
|
||||||
pendingWorldReplacements.registerPlatformEntryListener();
|
|
||||||
boolean enabled;
|
|
||||||
try {
|
try {
|
||||||
enabled = enable();
|
generatorResolver.refusals().attach(this);
|
||||||
|
IrisPlatforms.bind(new BukkitPlatform());
|
||||||
|
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
|
||||||
|
pendingWorldReplacements.registerPlatformEntryListener();
|
||||||
|
if (!INMS.isBound()) {
|
||||||
|
refuseUnsupportedServer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enable();
|
||||||
} catch (Throwable failure) {
|
} catch (Throwable failure) {
|
||||||
refuseVanillaFallback(failure);
|
if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
|
||||||
throw failure;
|
generatorResolver.refusals().startupWorldsCreated())) {
|
||||||
}
|
throw failure;
|
||||||
if (!enabled) {
|
}
|
||||||
refuseVanillaFallback(null);
|
enableFailed = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
BukkitGuiHost.install();
|
BukkitGuiHost.install();
|
||||||
@@ -939,54 +931,32 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
reportLockedRuntime();
|
reportLockedRuntime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void refuseUnsupportedServer() {
|
||||||
|
Throwable bindFailure = INMS.bindFailure();
|
||||||
|
IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null
|
||||||
|
? "no NMS binding is available for this server version."
|
||||||
|
: bindFailure.getMessage()), bindFailure);
|
||||||
|
if (FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
|
||||||
|
generatorResolver.refusals().startupWorldsCreated())) {
|
||||||
|
enableFailed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Iris.error(failure.getMessage());
|
||||||
|
// Deferred one tick: disablePlugin from inside onEnable re-enters onDisable
|
||||||
|
// synchronously and the loader then continues registering the half-enabled plugin.
|
||||||
|
J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1);
|
||||||
|
}
|
||||||
|
|
||||||
private static void reportLockedRuntime() {
|
private static void reportLockedRuntime() {
|
||||||
String denial = IrisStartupValidation.denialReason().orElse(null);
|
String denial = IrisStartupValidation.denialReason().orElse(null);
|
||||||
if (denial == null) {
|
if (denial == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
boolean managedStorage;
|
for (String line : RuntimeLockNotice.compose(denial, FailedEnableLock.irisWorldsPresent())) {
|
||||||
try {
|
|
||||||
managedStorage = IrisWorldStorage.hasManagedWorldStorage(IrisWorldStorage.levelRoot());
|
|
||||||
} catch (Throwable unavailable) {
|
|
||||||
Iris.reportError("Could not inspect Iris world storage while reporting the locked runtime.", unavailable);
|
|
||||||
managedStorage = false;
|
|
||||||
}
|
|
||||||
for (String line : RuntimeLockNotice.compose(denial, managedStorage)) {
|
|
||||||
Iris.error(line);
|
Iris.error(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stops a server whose Iris worlds would otherwise be generated by the vanilla generator.
|
|
||||||
* <p>
|
|
||||||
* A disabled Iris gets no {@code getDefaultWorldGenerator} call at all, so the server falls back to
|
|
||||||
* vanilla for every world bukkit.yml points at Iris and writes vanilla terrain into their region files.
|
|
||||||
* There is no Bukkit API that refuses a world at that point, so the server is stopped instead. This is
|
|
||||||
* damage control, not prevention: level creation runs in the same startup step that enables plugins, so
|
|
||||||
* spawn chunks of the affected worlds can still be written before the stop takes effect. The prevention
|
|
||||||
* lives in IrisBootstrap, which refuses startup before any level is created.
|
|
||||||
*/
|
|
||||||
private static void refuseVanillaFallback(Throwable failure) {
|
|
||||||
File levelRoot;
|
|
||||||
try {
|
|
||||||
levelRoot = IrisWorldStorage.levelRoot();
|
|
||||||
} catch (Throwable unavailable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!IrisWorldStorage.hasManagedWorldStorage(levelRoot)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Iris.error("Iris did not enable and this server has Iris worlds; stopping the server before they generate vanilla terrain.");
|
|
||||||
if (failure != null) {
|
|
||||||
Iris.reportError("Iris enable failed", failure);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Bukkit.shutdown();
|
|
||||||
} catch (Throwable unavailable) {
|
|
||||||
Iris.error("Could not stop the server: " + unavailable.getClass().getSimpleName());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public BukkitDebugDump debugDump() {
|
public BukkitDebugDump debugDump() {
|
||||||
return debugDump;
|
return debugDump;
|
||||||
}
|
}
|
||||||
@@ -1014,11 +984,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
}
|
}
|
||||||
IrisLanguage.shutdown();
|
IrisLanguage.shutdown();
|
||||||
teardownPapi();
|
teardownPapi();
|
||||||
boolean serverStopping = IrisToolbelt.isServerStopping();
|
boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping());
|
||||||
boolean restartingAtStartupBoundary = startupBoundaryRestart.get();
|
boolean restartingAtStartupBoundary = startupBoundaryRestart.get();
|
||||||
if (restartingAtStartupBoundary) {
|
if (restartingAtStartupBoundary) {
|
||||||
teardownRuntime("startup-boundary-restart", 30L);
|
teardownRuntime("startup-boundary-restart", 30L);
|
||||||
} else if (serverStopping) {
|
} else if (deferToServerStop) {
|
||||||
quiesceRuntimeForServerShutdown("onDisable");
|
quiesceRuntimeForServerShutdown("onDisable");
|
||||||
startPostStopFinisher();
|
startPostStopFinisher();
|
||||||
} else {
|
} else {
|
||||||
@@ -1036,26 +1006,27 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
}
|
}
|
||||||
// super.onDisable() cancels plugin tasks and unregisters every listener.
|
// super.onDisable() cancels plugin tasks and unregisters every listener.
|
||||||
super.onDisable();
|
super.onDisable();
|
||||||
if (!serverStopping || restartingAtStartupBoundary) {
|
if (!deferToServerStop || restartingAtStartupBoundary) {
|
||||||
finishTerminalCleanup();
|
finishTerminalCleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onPreUnload(ReloadAware.PreUnloadReason reason) {
|
public CompletionStage<Void> commitReload(ReloadAware.PreUnloadReason reason) {
|
||||||
teardownPapi();
|
teardownPapi();
|
||||||
if (IrisToolbelt.isServerStopping()) {
|
if (IrisToolbelt.isServerStopping()) {
|
||||||
quiesceRuntimeForServerShutdown("pre-unload:" + reason);
|
quiesceRuntimeForServerShutdown("pre-unload:" + reason);
|
||||||
startPostStopFinisher();
|
startPostStopFinisher();
|
||||||
Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers.");
|
Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers.");
|
||||||
return;
|
return CompletableFuture.completedFuture(null);
|
||||||
}
|
}
|
||||||
if (alreadyDrained.get()) {
|
if (alreadyDrained.get()) {
|
||||||
Iris.debug("Pre-unload hook skipped; Iris already drained.");
|
Iris.debug("Pre-unload hook skipped; Iris already drained.");
|
||||||
return;
|
return CompletableFuture.completedFuture(null);
|
||||||
}
|
}
|
||||||
Iris.debug("BileTools pre-unload hook fired (" + reason + "). Freezing all Iris worlds.");
|
Iris.debug("BileTools pre-unload hook fired (" + reason + "). Freezing all Iris worlds.");
|
||||||
drainOnce("pre-unload:" + reason, 45L);
|
drainOnce("pre-unload:" + reason, 45L);
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1457,6 +1428,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
return generatorResolver.resolveDefaultWorldGenerator(worldName, id);
|
return generatorResolver.resolveDefaultWorldGenerator(worldName, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ChunkGenerator requireWorldGenerator(String worldName, String id) {
|
||||||
|
return generatorResolver.requireWorldGenerator(worldName, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorldRefusalReporter worldRefusals() {
|
||||||
|
return generatorResolver.refusals();
|
||||||
|
}
|
||||||
|
|
||||||
public void splash() {
|
public void splash() {
|
||||||
Iris.info("Custom Biomes: " + INMS.get().countCustomBiomes());
|
Iris.info("Custom Biomes: " + INMS.get().countCustomBiomes());
|
||||||
printPacks();
|
printPacks();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import art.arcane.iris.pack.datapack.DataVersion;
|
|||||||
import io.papermc.paper.ServerBuildInfo;
|
import io.papermc.paper.ServerBuildInfo;
|
||||||
import art.arcane.iris.pack.DefaultPackBootstrapProvisioner.ProvisionResult;
|
import art.arcane.iris.pack.DefaultPackBootstrapProvisioner.ProvisionResult;
|
||||||
import art.arcane.iris.platform.bootstrap.SlimJar;
|
import art.arcane.iris.platform.bootstrap.SlimJar;
|
||||||
|
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||||
import io.papermc.paper.plugin.bootstrap.BootstrapContext;
|
import io.papermc.paper.plugin.bootstrap.BootstrapContext;
|
||||||
import io.papermc.paper.plugin.bootstrap.PluginBootstrap;
|
import io.papermc.paper.plugin.bootstrap.PluginBootstrap;
|
||||||
import io.papermc.paper.plugin.lifecycle.event.LifecycleEvent;
|
import io.papermc.paper.plugin.lifecycle.event.LifecycleEvent;
|
||||||
@@ -21,6 +22,7 @@ import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
@@ -112,16 +114,18 @@ public final class IrisBootstrap implements PluginBootstrap {
|
|||||||
* Stops startup when a world the server is about to load carries Iris storage Iris cannot generate from.
|
* Stops startup when a world the server is about to load carries Iris storage Iris cannot generate from.
|
||||||
* <p>
|
* <p>
|
||||||
* The server enumerates levels from {@code <levelRoot>/dimensions/<namespace>/<key>} on disk, so a world
|
* The server enumerates levels from {@code <levelRoot>/dimensions/<namespace>/<key>} on disk, so a world
|
||||||
* whose folder is present is going to be loaded whatever Iris does; and CraftBukkit swallows whatever
|
* whose folder is present is going to be loaded whatever Iris does. The generator resolver answers a world
|
||||||
* {@code getDefaultWorldGenerator} throws and falls back to the vanilla generator, which writes vanilla
|
* it cannot generate with a fail-closed generator, which stops startup at that world's own level creation,
|
||||||
* terrain straight into that world's region files. A pack snapshot that has gone missing therefore has
|
* after the levels before it have loaded. A pack snapshot that has gone missing is found here instead,
|
||||||
* to be found here, before any level is created, exactly as a corrupt {@code dimensions/<id>.json} is.
|
* before any level is created, exactly as a corrupt {@code dimensions/<id>.json} is. An Iris world in a
|
||||||
|
* vanilla slot whose bukkit.yml entry no longer names Iris stops startup too: CraftServer would build it
|
||||||
|
* with the vanilla generator without ever asking Iris.
|
||||||
* <p>
|
* <p>
|
||||||
* A world with no folder at all is not a startup failure: nothing enumerates it, nothing loads it, and
|
* A world with no folder at all is not a startup failure: nothing enumerates it, nothing loads it, and
|
||||||
* its bukkit.yml and Multiverse entries are what make restoring the folder a complete recovery. Neither
|
* its bukkit.yml and Multiverse entries are what make restoring the folder a complete recovery. Neither
|
||||||
* is a folder that holds no pack snapshot and no world data - {@link #quarantineWorthlessHusks} has
|
* is a folder that holds no pack snapshot and no world data - {@link #quarantineWorthlessHusks} has
|
||||||
* already moved those out of the dimensions tree, and one that survives it (a Spigot-layout world folder,
|
* already moved those out of the dimensions tree, and one that survives it (a Spigot-layout world folder,
|
||||||
* or a move that failed) owns nothing a vanilla generator could destroy. Both are only reported.
|
* or a move that failed) owns nothing to lose. Both are only reported here.
|
||||||
*/
|
*/
|
||||||
static void requireUsableWorldStorage(BukkitStartupPaths startupPaths) throws IOException {
|
static void requireUsableWorldStorage(BukkitStartupPaths startupPaths) throws IOException {
|
||||||
Path levelRoot = startupPaths.levelRoot();
|
Path levelRoot = startupPaths.levelRoot();
|
||||||
@@ -135,6 +139,7 @@ public final class IrisBootstrap implements PluginBootstrap {
|
|||||||
levelRoot
|
levelRoot
|
||||||
);
|
);
|
||||||
StringBuilder unusable = new StringBuilder();
|
StringBuilder unusable = new StringBuilder();
|
||||||
|
StringBuilder unbound = new StringBuilder();
|
||||||
for (IrisWorldStorageEntry entry : entries) {
|
for (IrisWorldStorageEntry entry : entries) {
|
||||||
switch (entry.state()) {
|
switch (entry.state()) {
|
||||||
case PRESENT -> {
|
case PRESENT -> {
|
||||||
@@ -152,15 +157,29 @@ public final class IrisBootstrap implements PluginBootstrap {
|
|||||||
.append(": ")
|
.append(": ")
|
||||||
.append(entry.detail() == null ? "storage is unusable" : entry.detail())
|
.append(entry.detail() == null ? "storage is unusable" : entry.detail())
|
||||||
.append(')');
|
.append(')');
|
||||||
|
case UNBOUND -> unbound.append(unbound.isEmpty() ? "" : "; ")
|
||||||
|
.append(entry.configuredWorldName())
|
||||||
|
.append(" (")
|
||||||
|
.append(entry.storagePath())
|
||||||
|
.append("): set worlds.")
|
||||||
|
.append(entry.configuredWorldName())
|
||||||
|
.append(".generator to Iris:<pack> in bukkit.yml, or move the folder aside to start a new"
|
||||||
|
+ " vanilla world there");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (unusable.isEmpty()) {
|
List<String> refusals = new ArrayList<>(2);
|
||||||
return;
|
if (!unusable.isEmpty()) {
|
||||||
|
refusals.add("Iris world storage is unusable and the server would generate vanilla terrain over it: "
|
||||||
|
+ unusable + ". Restore each world folder from a backup, or move it aside; a world with no"
|
||||||
|
+ " folder is reported and skipped, and the server starts.");
|
||||||
|
}
|
||||||
|
if (!unbound.isEmpty()) {
|
||||||
|
refusals.add("Iris worlds in vanilla slots are not bound to Iris, so the server would generate vanilla"
|
||||||
|
+ " terrain into them: " + unbound + ".");
|
||||||
|
}
|
||||||
|
if (!refusals.isEmpty()) {
|
||||||
|
throw new IllegalStateException(String.join(" ", refusals));
|
||||||
}
|
}
|
||||||
throw new IllegalStateException("Iris world storage is unusable and the server would generate vanilla"
|
|
||||||
+ " terrain over it: " + unusable
|
|
||||||
+ ". Restore each world folder from a backup, or move it aside; a world with no folder is"
|
|
||||||
+ " reported and skipped, and the server starts.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void armStartupFailure(BootstrapContext context, Throwable failure) {
|
static void armStartupFailure(BootstrapContext context, Throwable failure) {
|
||||||
@@ -175,17 +194,30 @@ public final class IrisBootstrap implements PluginBootstrap {
|
|||||||
context.getLifecycleManager().registerEventHandler(eventType, event -> {
|
context.getLifecycleManager().registerEventHandler(eventType, event -> {
|
||||||
throw new IllegalStateException("Iris bootstrap did not establish safe world-generation state.", failure);
|
throw new IllegalStateException("Iris bootstrap did not establish safe world-generation state.", failure);
|
||||||
});
|
});
|
||||||
|
List<String> notice = startupRefusalNotice(failure);
|
||||||
try {
|
try {
|
||||||
context.getLogger().error(
|
for (String line : notice) {
|
||||||
"Iris bootstrap failed; registry and world startup will be stopped at datapack discovery.",
|
context.getLogger().error(line);
|
||||||
failure
|
}
|
||||||
);
|
context.getLogger().warn("Iris bootstrap failure", failure);
|
||||||
} catch (Throwable loggingFailure) {
|
} catch (Throwable loggingFailure) {
|
||||||
|
notice.forEach(System.err::println);
|
||||||
failure.addSuppressed(loggingFailure);
|
failure.addSuppressed(loggingFailure);
|
||||||
failure.printStackTrace(System.err);
|
failure.printStackTrace(System.err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<String> startupRefusalNotice(Throwable failure) {
|
||||||
|
return GenerationRefusalNotice.compose(
|
||||||
|
"Iris stopped server startup before any world loads",
|
||||||
|
GenerationRefusalNotice.causes(failure),
|
||||||
|
List.of(
|
||||||
|
"Startup stops at datapack discovery: no world loads and no chunks are written.",
|
||||||
|
"Fix the cause, then start the server again. Paper's --safeMode hint does not apply:"
|
||||||
|
+ " Iris worlds must not load without Iris."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
private static ProvisionResult provision(BootstrapContext context, BukkitStartupPaths startupPaths) {
|
private static ProvisionResult provision(BootstrapContext context, BukkitStartupPaths startupPaths) {
|
||||||
try {
|
try {
|
||||||
return DefaultPackBootstrapProvisioner.provision(new BootstrapRequest(
|
return DefaultPackBootstrapProvisioner.provision(new BootstrapRequest(
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package art.arcane.iris.api.tree;
|
package art.arcane.iris.api.tree;
|
||||||
|
|
||||||
public interface TreeFellerRunHooks {
|
public interface TreeFellerRunHooks {
|
||||||
|
default boolean requiresSneaking() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
TreeFellerRunHooks NONE = new TreeFellerRunHooks() {
|
TreeFellerRunHooks NONE = new TreeFellerRunHooks() {
|
||||||
@Override
|
@Override
|
||||||
public void onActivationAccepted() {
|
public void onActivationAccepted() {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import art.arcane.iris.localization.IrisLanguage;
|
|||||||
import art.arcane.iris.pack.datapack.DatapackIngestService;
|
import art.arcane.iris.pack.datapack.DatapackIngestService;
|
||||||
import art.arcane.iris.studio.object.ObjectStudioSaveService;
|
import art.arcane.iris.studio.object.ObjectStudioSaveService;
|
||||||
import art.arcane.iris.generation.runtime.Engine;
|
import art.arcane.iris.generation.runtime.Engine;
|
||||||
|
import art.arcane.iris.generation.locator.SubterrainLocator;
|
||||||
|
import art.arcane.iris.generation.subterrain.IrisSubterrainFamily;
|
||||||
import art.arcane.iris.structure.placement.IrisStructureLocator;
|
import art.arcane.iris.structure.placement.IrisStructureLocator;
|
||||||
import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy;
|
import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy;
|
||||||
import art.arcane.iris.structure.placement.StructureReachability;
|
import art.arcane.iris.structure.placement.StructureReachability;
|
||||||
@@ -72,7 +74,7 @@ import java.util.Set;
|
|||||||
|
|
||||||
@Director(name = "find", origin = DirectorOrigin.PLAYER, description = "Iris Find commands", descriptionKey = "iris.director.commandfind.director.iris_find_commands", aliases = "goto")
|
@Director(name = "find", origin = DirectorOrigin.PLAYER, description = "Iris Find commands", descriptionKey = "iris.director.commandfind.director.iris_find_commands", aliases = "goto")
|
||||||
public class CommandFind implements DirectorExecutor {
|
public class CommandFind implements DirectorExecutor {
|
||||||
@Director(description = "Find a biome", descriptionKey = "iris.director.commandfind.director.find_biome")
|
@Director(description = "Find a biome", sync = true, descriptionKey = "iris.director.commandfind.director.find_biome")
|
||||||
public void biome(
|
public void biome(
|
||||||
@Param(description = "The biome to look for", descriptionKey = "iris.director.commandfind.param.biome_look", customHandler = ReachableBiomeHandler.class)
|
@Param(description = "The biome to look for", descriptionKey = "iris.director.commandfind.param.biome_look", customHandler = ReachableBiomeHandler.class)
|
||||||
IrisBiome biome,
|
IrisBiome biome,
|
||||||
@@ -89,6 +91,78 @@ public class CommandFind implements DirectorExecutor {
|
|||||||
EngineBukkitOps.gotoBiome(e, biome, player(), teleport);
|
EngineBukkitOps.gotoBiome(e, biome, player(), teleport);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Director(description = "Find an authored underground feature by id or geometry family", sync = true)
|
||||||
|
public void subterrain(
|
||||||
|
@Param(description = "Feature definition id or family: tectonic_fault, cenote, lava_tube, travertine_terraces")
|
||||||
|
String feature,
|
||||||
|
@Param(description = "Search radius in blocks", defaultValue = "8192")
|
||||||
|
int radius,
|
||||||
|
@Param(description = "Should you be teleported", defaultValue = "true")
|
||||||
|
boolean teleport
|
||||||
|
) {
|
||||||
|
String key = feature.trim();
|
||||||
|
IrisSubterrainFamily family = null;
|
||||||
|
try {
|
||||||
|
family = IrisSubterrainFamily.valueOf(key.toUpperCase(Locale.ROOT).replace('-', '_'));
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
}
|
||||||
|
locateSubterrain(new SubterrainLocator.Query(family == null ? key : "", family, ""), radius, teleport);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Director(name = "underground-biome", description = "Find a bounded authored underground biome", sync = true)
|
||||||
|
public void undergroundBiome(
|
||||||
|
@Param(description = "The underground biome to look for", customHandler = ReachableBiomeHandler.class)
|
||||||
|
IrisBiome biome,
|
||||||
|
@Param(description = "Search radius in blocks", defaultValue = "8192")
|
||||||
|
int radius,
|
||||||
|
@Param(description = "Should you be teleported", defaultValue = "true")
|
||||||
|
boolean teleport
|
||||||
|
) {
|
||||||
|
locateSubterrain(new SubterrainLocator.Query("", null, biome.getLoadKey()), radius, teleport);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void locateSubterrain(SubterrainLocator.Query query, int radius, boolean teleport) {
|
||||||
|
Engine activeEngine = engine();
|
||||||
|
VolmitSender commandSender = sender();
|
||||||
|
Player target = player();
|
||||||
|
if (activeEngine == null || commandSender == null || target == null) {
|
||||||
|
if (commandSender != null) {
|
||||||
|
commandSender.sendMessage(C.RED + "Run this command from an Iris world.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (radius < 0 || radius > 32768) {
|
||||||
|
commandSender.sendMessage(C.RED + "Search radius must be between 0 and 32768 blocks.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Location origin = target.getLocation();
|
||||||
|
World world = origin.getWorld();
|
||||||
|
commandSender.sendMessage(C.GRAY + "Searching authored underground plans...");
|
||||||
|
J.a(() -> {
|
||||||
|
try {
|
||||||
|
SubterrainLocator.Result found = GenerationSemanticQueries.nearestSubterrain(activeEngine, query,
|
||||||
|
origin.getBlockX(), origin.getBlockY(), origin.getBlockZ(), radius).orElse(null);
|
||||||
|
if (found == null) {
|
||||||
|
sendStructureMessage(target, commandSender,
|
||||||
|
C.YELLOW + "No matching authored underground volume found within " + radius + " blocks.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String label = found.family().name().toLowerCase(Locale.ROOT) + " " + found.featureId();
|
||||||
|
if (!teleport) {
|
||||||
|
sendStructureMessage(target, commandSender, C.GREEN + "Found " + label + " at "
|
||||||
|
+ found.x() + ", " + found.y() + ", " + found.z() + ".");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prepareStructureTeleport(target, world, commandSender, label,
|
||||||
|
new Location(world, found.x(), found.y(), found.z()), true);
|
||||||
|
} catch (Throwable error) {
|
||||||
|
sendStructureMessage(target, commandSender,
|
||||||
|
C.RED + "Could not locate authored underground volume: " + error.getMessage());
|
||||||
|
Iris.reportError("Could not locate authored underground volume.", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Director(description = "Find a region", descriptionKey = "iris.director.commandfind.director.find_region")
|
@Director(description = "Find a region", descriptionKey = "iris.director.commandfind.director.find_region")
|
||||||
public void region(
|
public void region(
|
||||||
@Param(description = "The region to look for", descriptionKey = "iris.director.commandfind.param.region_look", customHandler = ReachableRegionHandler.class)
|
@Param(description = "The region to look for", descriptionKey = "iris.director.commandfind.param.region_look", customHandler = ReachableRegionHandler.class)
|
||||||
|
|||||||
@@ -511,7 +511,9 @@ public class CommandIris implements DirectorExecutor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
PlatformChunkGenerator generator = IrisToolbelt.access(world);
|
PlatformChunkGenerator generator = IrisToolbelt.access(world);
|
||||||
IrisToolbelt.beginWorldMaintenance(world, "world-unload", true);
|
// Maintenance that forbids mantle stages refuses every chunk, and Folia treats a refused chunk as a chunk
|
||||||
|
// system failure, so it starts only once no player is left to request one.
|
||||||
|
AtomicBoolean maintenanceEntered = new AtomicBoolean(false);
|
||||||
try {
|
try {
|
||||||
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
|
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
|
||||||
CompletableFuture<Boolean> sequence = IrisToolbelt.evacuateAsync(world)
|
CompletableFuture<Boolean> sequence = IrisToolbelt.evacuateAsync(world)
|
||||||
@@ -523,6 +525,8 @@ public class CommandIris implements DirectorExecutor {
|
|||||||
if (!Boolean.TRUE.equals(evacuated)) {
|
if (!Boolean.TRUE.equals(evacuated)) {
|
||||||
return CompletableFuture.completedFuture(false);
|
return CompletableFuture.completedFuture(false);
|
||||||
}
|
}
|
||||||
|
IrisToolbelt.beginWorldMaintenance(world, "world-unload", true);
|
||||||
|
maintenanceEntered.set(true);
|
||||||
return WorldLifecycleService.get().unloadAsync(world, true);
|
return WorldLifecycleService.get().unloadAsync(world, true);
|
||||||
})
|
})
|
||||||
.thenCompose(unloaded -> {
|
.thenCompose(unloaded -> {
|
||||||
@@ -537,7 +541,9 @@ public class CommandIris implements DirectorExecutor {
|
|||||||
});
|
});
|
||||||
guardUnloadCompletion(sequence, terminalTimeout, world.getName())
|
guardUnloadCompletion(sequence, terminalTimeout, world.getName())
|
||||||
.whenComplete((unloaded, throwable) -> {
|
.whenComplete((unloaded, throwable) -> {
|
||||||
IrisToolbelt.endWorldMaintenance(world, "world-unload", true);
|
if (maintenanceEntered.get()) {
|
||||||
|
IrisToolbelt.endWorldMaintenance(world, "world-unload", true);
|
||||||
|
}
|
||||||
lease.close();
|
lease.close();
|
||||||
Runnable response = () -> reportUnloadResult(responseSender, world, unloaded, throwable);
|
Runnable response = () -> reportUnloadResult(responseSender, world, unloaded, throwable);
|
||||||
if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) {
|
if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) {
|
||||||
@@ -546,7 +552,9 @@ public class CommandIris implements DirectorExecutor {
|
|||||||
J.s(response);
|
J.s(response);
|
||||||
});
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
IrisToolbelt.endWorldMaintenance(world, "world-unload", true);
|
if (maintenanceEntered.get()) {
|
||||||
|
IrisToolbelt.endWorldMaintenance(world, "world-unload", true);
|
||||||
|
}
|
||||||
lease.close();
|
lease.close();
|
||||||
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
|
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
|
||||||
Iris.reportError("Failed to unload world \"" + world.getName() + "\".", e);
|
Iris.reportError("Failed to unload world \"" + world.getName() + "\".", e);
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
|
|
||||||
package art.arcane.iris.command;
|
package art.arcane.iris.command;
|
||||||
|
|
||||||
|
import art.arcane.iris.pack.PackFingerprints;
|
||||||
|
|
||||||
import art.arcane.iris.Iris;
|
import art.arcane.iris.Iris;
|
||||||
import art.arcane.iris.pack.validation.PackCompatReport;
|
import art.arcane.iris.pack.validation.PackCompatReport;
|
||||||
import art.arcane.iris.pack.PackDirectoryResolver;
|
import art.arcane.iris.pack.PackDirectoryResolver;
|
||||||
@@ -174,7 +176,6 @@ public class CommandPack implements DirectorExecutor {
|
|||||||
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_CLEANUP_CANDIDATES_FOUND_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
|
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_CLEANUP_CANDIDATES_FOUND_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
PackValidationRegistry.remove(packFolder.getName());
|
|
||||||
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_QUARANTINED_CLEANUP_CANDIDATE_S_UNDER, MessageArgument.untrusted("size", String.valueOf(result.quarantinedPaths().size())), MessageArgument.untrusted("quarantinePath", String.valueOf(result.quarantinePath()))));
|
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_QUARANTINED_CLEANUP_CANDIDATE_S_UNDER, MessageArgument.untrusted("size", String.valueOf(result.quarantinedPaths().size())), MessageArgument.untrusted("quarantinePath", String.valueOf(result.quarantinePath()))));
|
||||||
reportPaths(s, result.quarantinedPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_QUARANTINED);
|
reportPaths(s, result.quarantinedPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_QUARANTINED);
|
||||||
return;
|
return;
|
||||||
@@ -230,7 +231,6 @@ public class CommandPack implements DirectorExecutor {
|
|||||||
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NOTHING_RESTORE_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
|
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NOTHING_RESTORE_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
PackValidationRegistry.remove(packFolder.getName());
|
|
||||||
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_RESTORED_FILE_S_FROM, MessageArgument.untrusted("size", String.valueOf(result.restoredPaths().size())), MessageArgument.untrusted("dumpPath", String.valueOf(result.dumpPath()))));
|
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_RESTORED_FILE_S_FROM, MessageArgument.untrusted("size", String.valueOf(result.restoredPaths().size())), MessageArgument.untrusted("dumpPath", String.valueOf(result.dumpPath()))));
|
||||||
reportPaths(s, result.restoredPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_RESTORED);
|
reportPaths(s, result.restoredPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_RESTORED);
|
||||||
return;
|
return;
|
||||||
@@ -363,47 +363,79 @@ public class CommandPack implements DirectorExecutor {
|
|||||||
return persisted == null || persisted.isBlank() ? "unknown" : persisted;
|
return persisted == null || persisted.isBlank() ? "unknown" : persisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
private PackValidationResult runValidate(VolmitSender s, File packFolder) {
|
PackValidationResult runValidate(VolmitSender s, File packFolder) {
|
||||||
|
PackValidationRegistry.ValidationTicket ticket =
|
||||||
|
PackValidationRegistry.tryBeginValidation(packFolder.toPath());
|
||||||
|
PackValidationResult result;
|
||||||
|
String contentFingerprint = "";
|
||||||
|
String contextFingerprint = "";
|
||||||
try {
|
try {
|
||||||
PackValidationResult result = PackValidator.validate(packFolder);
|
if (ticket == null) {
|
||||||
PackValidationRegistry.publish(result);
|
throw new IllegalStateException("Pack content is currently being changed.");
|
||||||
reportResult(s, result);
|
}
|
||||||
return result;
|
contentFingerprint = PackFingerprints.computePackTreeFingerprint(packFolder);
|
||||||
|
contextFingerprint = PackValidationCache.contextFingerprint();
|
||||||
|
result = PackValidator.validate(packFolder);
|
||||||
|
if (!contentFingerprint.equals(PackFingerprints.computePackTreeFingerprint(packFolder))
|
||||||
|
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
|
||||||
|
throw new IllegalStateException("Pack content or validation context changed during validation.");
|
||||||
|
}
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
Iris.reportError("Pack validation failed for '" + packFolder.getName() + "'", e);
|
Iris.reportError("Pack validation failed for '" + packFolder.getName() + "'", e);
|
||||||
String detail = e.getMessage() == null || e.getMessage().isBlank()
|
String detail = e.getMessage() == null || e.getMessage().isBlank()
|
||||||
? e.getClass().getSimpleName()
|
? e.getClass().getSimpleName()
|
||||||
: e.getMessage();
|
: e.getMessage();
|
||||||
PackValidationResult result = new PackValidationResult(
|
result = new PackValidationResult(
|
||||||
packFolder.getName(),
|
packFolder.getName(),
|
||||||
List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail),
|
List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail),
|
||||||
List.of(),
|
List.of(),
|
||||||
System.currentTimeMillis());
|
System.currentTimeMillis());
|
||||||
PackValidationRegistry.publish(result);
|
contentFingerprint = "";
|
||||||
reportResult(s, result);
|
contextFingerprint = "";
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
if (PackValidationRegistry.publishIfCurrent(ticket, result, contentFingerprint, contextFingerprint)) {
|
||||||
|
PackValidationRegistry.publish(result);
|
||||||
|
} else {
|
||||||
|
result = new PackValidationResult(packFolder.getName(),
|
||||||
|
List.of("Pack content changed during validation."), List.of(), System.currentTimeMillis());
|
||||||
|
}
|
||||||
|
reportResult(s, result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void persistValidationCache(File packsRoot) {
|
private void persistValidationCache(File packsRoot) {
|
||||||
List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
|
try {
|
||||||
List<PackValidationResult> results = new ArrayList<>(packDirectories.size());
|
String contextFingerprint = PackValidationCache.contextFingerprint();
|
||||||
for (File packDirectory : packDirectories) {
|
PackFingerprints.PackContentSnapshot snapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
|
||||||
PackValidationResult result = PackValidationRegistry.get(packDirectory.getName());
|
List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
|
||||||
if (result == null) {
|
List<PackValidationResult> results = new ArrayList<>(packDirectories.size());
|
||||||
// The boot cache only loads when it covers every pack, so a partial write is
|
for (File packDirectory : packDirectories) {
|
||||||
// pointless - but say so instead of silently skipping the persist forever.
|
PackValidationResult result = PackValidationRegistry.getMatching(
|
||||||
Iris.warn("Pack validation cache not written: \"" + packDirectory.getName()
|
packDirectory.toPath(), snapshot.packContents().get(packDirectory.getName()));
|
||||||
+ "\" has no registered result. Run /iris pack validate all to repopulate it.");
|
if (result == null) {
|
||||||
|
Iris.warn("Pack validation cache not written: \"" + packDirectory.getName()
|
||||||
|
+ "\" has no validation matching its current content. Run /iris pack validate to refresh it.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
results.add(result);
|
||||||
|
}
|
||||||
|
if (!snapshot.content().equals(PackFingerprints.computePackContentSnapshot(packsRoot).content())
|
||||||
|
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
|
||||||
|
Iris.warn("Pack validation cache not written because pack content or validation context changed.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
results.add(result);
|
for (int index = 0; index < packDirectories.size(); index++) {
|
||||||
}
|
File packDirectory = packDirectories.get(index);
|
||||||
try {
|
if (PackValidationRegistry.getMatching(packDirectory.toPath(),
|
||||||
|
snapshot.packContents().get(packDirectory.getName())) != results.get(index)) {
|
||||||
|
Iris.warn("Pack validation cache not written because validation changed during persistence.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
PackValidationCache.save(
|
PackValidationCache.save(
|
||||||
Iris.instance.getDataFile("cache", "pack-validation.json").toPath(),
|
Iris.instance.getDataFile("cache", "pack-validation.json").toPath(),
|
||||||
PackValidationCache.contentFingerprint(packsRoot),
|
snapshot.content(),
|
||||||
PackValidationCache.contextFingerprint(),
|
contextFingerprint,
|
||||||
results);
|
results);
|
||||||
} catch (IOException | RuntimeException e) {
|
} catch (IOException | RuntimeException e) {
|
||||||
Iris.reportError("Could not persist refreshed pack-validation results", e);
|
Iris.reportError("Could not persist refreshed pack-validation results", e);
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package art.arcane.iris.generation.runtime;
|
||||||
|
|
||||||
|
final class GenerationRateWindow {
|
||||||
|
static final long TIMING_IDLE_MILLIS = 10_000L;
|
||||||
|
|
||||||
|
private long lastSampleAtMs;
|
||||||
|
private int lastGeneratedCount;
|
||||||
|
private long lastGenerationAtMs;
|
||||||
|
|
||||||
|
double sampleChunksPerSecond(int generatedCount, long sampledAtMs) {
|
||||||
|
int safeGeneratedCount = Math.max(0, generatedCount);
|
||||||
|
long previousAt = lastSampleAtMs;
|
||||||
|
int previousCount = lastGeneratedCount;
|
||||||
|
lastSampleAtMs = sampledAtMs;
|
||||||
|
lastGeneratedCount = safeGeneratedCount;
|
||||||
|
if (previousAt <= 0L) {
|
||||||
|
if (safeGeneratedCount > 0) {
|
||||||
|
lastGenerationAtMs = sampledAtMs;
|
||||||
|
}
|
||||||
|
return 0D;
|
||||||
|
}
|
||||||
|
if (sampledAtMs <= previousAt) {
|
||||||
|
return 0D;
|
||||||
|
}
|
||||||
|
int generatedDelta = Math.max(0, safeGeneratedCount - previousCount);
|
||||||
|
if (generatedDelta > 0) {
|
||||||
|
lastGenerationAtMs = sampledAtMs;
|
||||||
|
}
|
||||||
|
return generatedDelta * 1000D / (sampledAtMs - previousAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean generationTimingsFresh(long sampledAtMs) {
|
||||||
|
return lastGenerationAtMs > 0L && sampledAtMs - lastGenerationAtMs <= TIMING_IDLE_MILLIS;
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-17
@@ -460,8 +460,12 @@ public final class IrisEngineSVC implements IrisService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
double chunksPerSecond = registered.sampleChunksPerSecond(engine.getGenerated(), now);
|
double chunksPerSecond = registered.generationRate.sampleChunksPerSecond(engine.getGenerated(), now);
|
||||||
worldSnapshots.add(EngineTelemetrySnapshot.capture(engine, chunksPerSecond, now));
|
worldSnapshots.add(EngineTelemetrySnapshot.capture(
|
||||||
|
engine,
|
||||||
|
chunksPerSecond,
|
||||||
|
registered.generationRate.generationTimingsFresh(now),
|
||||||
|
now));
|
||||||
registered.clearMetricsFailure();
|
registered.clearMetricsFailure();
|
||||||
} catch (Throwable exception) {
|
} catch (Throwable exception) {
|
||||||
if (EngineMaintenance.isMantleClosed(exception)) {
|
if (EngineMaintenance.isMantleClosed(exception)) {
|
||||||
@@ -552,6 +556,7 @@ public final class IrisEngineSVC implements IrisService {
|
|||||||
progress.generated(),
|
progress.generated(),
|
||||||
progress.totalChunks(),
|
progress.totalChunks(),
|
||||||
progress.chunksRemaining(),
|
progress.chunksRemaining(),
|
||||||
|
progress.chunksInFlight(),
|
||||||
progress.chunksPerSecond(),
|
progress.chunksPerSecond(),
|
||||||
progress.eta(),
|
progress.eta(),
|
||||||
progress.elapsed(),
|
progress.elapsed(),
|
||||||
@@ -612,8 +617,7 @@ public final class IrisEngineSVC implements IrisService {
|
|||||||
private final AtomicReference<CompletableFuture<Void>> activeMaintenance = new AtomicReference<>();
|
private final AtomicReference<CompletableFuture<Void>> activeMaintenance = new AtomicReference<>();
|
||||||
private volatile ScheduledFuture<?> maintenance;
|
private volatile ScheduledFuture<?> maintenance;
|
||||||
private volatile boolean closed;
|
private volatile boolean closed;
|
||||||
private long lastGeneratedSampleAtMs;
|
private final GenerationRateWindow generationRate = new GenerationRateWindow();
|
||||||
private int lastGeneratedCount;
|
|
||||||
|
|
||||||
private Registered(Registration registration, ScheduledThreadPoolExecutor executor) {
|
private Registered(Registration registration, ScheduledThreadPoolExecutor executor) {
|
||||||
name = registration.name();
|
name = registration.name();
|
||||||
@@ -696,19 +700,6 @@ public final class IrisEngineSVC implements IrisService {
|
|||||||
return activeMaintenance.get() != null;
|
return activeMaintenance.get() != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private double sampleChunksPerSecond(int generatedCount, long sampledAtMs) {
|
|
||||||
int safeGeneratedCount = Math.max(0, generatedCount);
|
|
||||||
long previousAt = lastGeneratedSampleAtMs;
|
|
||||||
int previousCount = lastGeneratedCount;
|
|
||||||
lastGeneratedSampleAtMs = sampledAtMs;
|
|
||||||
lastGeneratedCount = safeGeneratedCount;
|
|
||||||
if (previousAt <= 0L || sampledAtMs <= previousAt) {
|
|
||||||
return 0D;
|
|
||||||
}
|
|
||||||
int generatedDelta = Math.max(0, safeGeneratedCount - previousCount);
|
|
||||||
return generatedDelta * 1000D / (sampledAtMs - previousAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void close() {
|
private void close() {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+3
@@ -101,6 +101,7 @@ public record IrisTelemetrySnapshot(
|
|||||||
long generated,
|
long generated,
|
||||||
long total,
|
long total,
|
||||||
long remaining,
|
long remaining,
|
||||||
|
long inFlight,
|
||||||
double chunksPerSecond,
|
double chunksPerSecond,
|
||||||
long etaMs,
|
long etaMs,
|
||||||
long elapsedMs,
|
long elapsedMs,
|
||||||
@@ -115,6 +116,7 @@ public record IrisTelemetrySnapshot(
|
|||||||
0L,
|
0L,
|
||||||
0L,
|
0L,
|
||||||
0L,
|
0L,
|
||||||
|
0L,
|
||||||
0D,
|
0D,
|
||||||
0L,
|
0L,
|
||||||
0L,
|
0L,
|
||||||
@@ -130,6 +132,7 @@ public record IrisTelemetrySnapshot(
|
|||||||
generated = Math.max(0L, generated);
|
generated = Math.max(0L, generated);
|
||||||
total = Math.max(0L, total);
|
total = Math.max(0L, total);
|
||||||
remaining = Math.max(0L, remaining);
|
remaining = Math.max(0L, remaining);
|
||||||
|
inFlight = Math.max(0L, inFlight);
|
||||||
chunksPerSecond = Double.isFinite(chunksPerSecond) ? Math.max(0D, chunksPerSecond) : 0D;
|
chunksPerSecond = Double.isFinite(chunksPerSecond) ? Math.max(0D, chunksPerSecond) : 0D;
|
||||||
etaMs = Math.max(0L, etaMs);
|
etaMs = Math.max(0L, etaMs);
|
||||||
elapsedMs = Math.max(0L, elapsedMs);
|
elapsedMs = Math.max(0L, elapsedMs);
|
||||||
|
|||||||
+32
-2
@@ -6,6 +6,7 @@ import art.arcane.iris.generation.runtime.IrisTelemetrySnapshot;
|
|||||||
import art.arcane.iris.Iris;
|
import art.arcane.iris.Iris;
|
||||||
import art.arcane.iris.generation.runtime.EngineTelemetrySnapshot;
|
import art.arcane.iris.generation.runtime.EngineTelemetrySnapshot;
|
||||||
import art.arcane.iris.spi.IrisServices;
|
import art.arcane.iris.spi.IrisServices;
|
||||||
|
import art.arcane.iris.world.task.J;
|
||||||
import art.arcane.iris.platform.bukkit.plugin.IrisService;
|
import art.arcane.iris.platform.bukkit.plugin.IrisService;
|
||||||
import art.arcane.volmlib.integration.IntegrationHandshakeRequest;
|
import art.arcane.volmlib.integration.IntegrationHandshakeRequest;
|
||||||
import art.arcane.volmlib.integration.IntegrationHandshakeResponse;
|
import art.arcane.volmlib.integration.IntegrationHandshakeResponse;
|
||||||
@@ -13,6 +14,9 @@ import art.arcane.volmlib.integration.IntegrationHeartbeat;
|
|||||||
import art.arcane.volmlib.integration.IntegrationMetricDescriptor;
|
import art.arcane.volmlib.integration.IntegrationMetricDescriptor;
|
||||||
import art.arcane.volmlib.integration.IntegrationMetricGroup;
|
import art.arcane.volmlib.integration.IntegrationMetricGroup;
|
||||||
import art.arcane.volmlib.integration.IntegrationMetricSample;
|
import art.arcane.volmlib.integration.IntegrationMetricSample;
|
||||||
|
import art.arcane.volmlib.integration.IntegrationMetricPublisher;
|
||||||
|
import art.arcane.volmlib.integration.IntegrationMetricSnapshot;
|
||||||
|
import art.arcane.volmlib.integration.IntegrationSnapshotProvider;
|
||||||
import art.arcane.volmlib.integration.IntegrationMetricSchema;
|
import art.arcane.volmlib.integration.IntegrationMetricSchema;
|
||||||
import art.arcane.volmlib.integration.IntegrationProtocolNegotiator;
|
import art.arcane.volmlib.integration.IntegrationProtocolNegotiator;
|
||||||
import art.arcane.volmlib.integration.IntegrationProtocolVersion;
|
import art.arcane.volmlib.integration.IntegrationProtocolVersion;
|
||||||
@@ -30,7 +34,7 @@ import java.util.Optional;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
public class IrisIntegrationService implements IrisService, IntegrationServiceContract {
|
public class IrisIntegrationService implements IrisService, IntegrationSnapshotProvider {
|
||||||
private static final IntegrationProtocolVersion CURRENT_PROTOCOL = new IntegrationProtocolVersion(1, 2);
|
private static final IntegrationProtocolVersion CURRENT_PROTOCOL = new IntegrationProtocolVersion(1, 2);
|
||||||
private static final Set<IntegrationProtocolVersion> SUPPORTED_PROTOCOLS = Set.of(
|
private static final Set<IntegrationProtocolVersion> SUPPORTED_PROTOCOLS = Set.of(
|
||||||
new IntegrationProtocolVersion(1, 0),
|
new IntegrationProtocolVersion(1, 0),
|
||||||
@@ -41,6 +45,7 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
|
|||||||
"handshake",
|
"handshake",
|
||||||
"heartbeat",
|
"heartbeat",
|
||||||
"metrics",
|
"metrics",
|
||||||
|
IntegrationSnapshotProvider.CAPABILITY,
|
||||||
"metric-groups",
|
"metric-groups",
|
||||||
"iris-engine-metrics",
|
"iris-engine-metrics",
|
||||||
"iris-world-metrics"
|
"iris-world-metrics"
|
||||||
@@ -63,6 +68,9 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
|
|||||||
);
|
);
|
||||||
|
|
||||||
private final Supplier<IrisTelemetrySnapshot> telemetrySupplier;
|
private final Supplier<IrisTelemetrySnapshot> telemetrySupplier;
|
||||||
|
private final IntegrationMetricPublisher snapshots = new IntegrationMetricPublisher(
|
||||||
|
IntegrationMetricSchema.irisKeys().size(), 30_000L);
|
||||||
|
private int snapshotTaskId = -1;
|
||||||
|
|
||||||
public IrisIntegrationService() {
|
public IrisIntegrationService() {
|
||||||
this(IrisIntegrationService::currentTelemetry);
|
this(IrisIntegrationService::currentTelemetry);
|
||||||
@@ -77,11 +85,17 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
|
|||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
Bukkit.getServicesManager().register(IntegrationServiceContract.class, this, Iris.instance, ServicePriority.Normal);
|
Bukkit.getServicesManager().register(IntegrationServiceContract.class, this, Iris.instance, ServicePriority.Normal);
|
||||||
|
snapshotTaskId = J.ar(this::publishSnapshots, 20);
|
||||||
Iris.verbose("Integration provider registered for Iris");
|
Iris.verbose("Integration provider registered for Iris");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisable() {
|
public void onDisable() {
|
||||||
|
if (snapshotTaskId != -1) {
|
||||||
|
J.car(snapshotTaskId);
|
||||||
|
snapshotTaskId = -1;
|
||||||
|
}
|
||||||
|
snapshots.clear();
|
||||||
Bukkit.getServicesManager().unregister(IntegrationServiceContract.class, this);
|
Bukkit.getServicesManager().unregister(IntegrationServiceContract.class, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +202,20 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
|
|||||||
return Map.copyOf(selected);
|
return Map.copyOf(selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IntegrationMetricSnapshot snapshotMetrics(Set<String> metricKeys) {
|
||||||
|
return snapshots.snapshotMetrics(metricKeys, System.currentTimeMillis());
|
||||||
|
}
|
||||||
|
|
||||||
|
void publishSnapshots() {
|
||||||
|
IntegrationMetricPublisher.Demand demand = snapshots.demandedKeys(System.currentTimeMillis());
|
||||||
|
if (demand.keys().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, IntegrationMetricSample> samples = sampleMetrics(demand.keys());
|
||||||
|
snapshots.publish(demand, System.currentTimeMillis(), samples);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<IntegrationMetricGroup> metricGroups() {
|
public List<IntegrationMetricGroup> metricGroups() {
|
||||||
IrisTelemetrySnapshot snapshot = safeSnapshot();
|
IrisTelemetrySnapshot snapshot = safeSnapshot();
|
||||||
@@ -319,6 +347,7 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
|
|||||||
IntegrationMetricSchema.IRIS_PREGEN_GENERATED,
|
IntegrationMetricSchema.IRIS_PREGEN_GENERATED,
|
||||||
IntegrationMetricSchema.IRIS_PREGEN_TOTAL,
|
IntegrationMetricSchema.IRIS_PREGEN_TOTAL,
|
||||||
IntegrationMetricSchema.IRIS_PREGEN_QUEUE,
|
IntegrationMetricSchema.IRIS_PREGEN_QUEUE,
|
||||||
|
IntegrationMetricSchema.IRIS_PREGEN_REMAINING,
|
||||||
IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT,
|
IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT,
|
||||||
IntegrationMetricSchema.IRIS_PREGEN_ETA_MS,
|
IntegrationMetricSchema.IRIS_PREGEN_ETA_MS,
|
||||||
IntegrationMetricSchema.IRIS_PREGEN_ELAPSED_MS,
|
IntegrationMetricSchema.IRIS_PREGEN_ELAPSED_MS,
|
||||||
@@ -332,7 +361,8 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
|
|||||||
put(samples, IntegrationMetricSchema.IRIS_PREGEN_PROGRESS, pregenerator.progressPercent(), sampledAtMs);
|
put(samples, IntegrationMetricSchema.IRIS_PREGEN_PROGRESS, pregenerator.progressPercent(), sampledAtMs);
|
||||||
put(samples, IntegrationMetricSchema.IRIS_PREGEN_GENERATED, pregenerator.generated(), sampledAtMs);
|
put(samples, IntegrationMetricSchema.IRIS_PREGEN_GENERATED, pregenerator.generated(), sampledAtMs);
|
||||||
put(samples, IntegrationMetricSchema.IRIS_PREGEN_TOTAL, pregenerator.total(), sampledAtMs);
|
put(samples, IntegrationMetricSchema.IRIS_PREGEN_TOTAL, pregenerator.total(), sampledAtMs);
|
||||||
put(samples, IntegrationMetricSchema.IRIS_PREGEN_QUEUE, pregenerator.remaining(), sampledAtMs);
|
put(samples, IntegrationMetricSchema.IRIS_PREGEN_QUEUE, pregenerator.inFlight(), sampledAtMs);
|
||||||
|
put(samples, IntegrationMetricSchema.IRIS_PREGEN_REMAINING, pregenerator.remaining(), sampledAtMs);
|
||||||
put(samples, IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT, pregenerator.chunksPerSecond(), sampledAtMs);
|
put(samples, IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT, pregenerator.chunksPerSecond(), sampledAtMs);
|
||||||
put(samples, IntegrationMetricSchema.IRIS_PREGEN_ETA_MS, pregenerator.etaMs(), sampledAtMs);
|
put(samples, IntegrationMetricSchema.IRIS_PREGEN_ETA_MS, pregenerator.etaMs(), sampledAtMs);
|
||||||
put(samples, IntegrationMetricSchema.IRIS_PREGEN_ELAPSED_MS, pregenerator.elapsedMs(), sampledAtMs);
|
put(samples, IntegrationMetricSchema.IRIS_PREGEN_ELAPSED_MS, pregenerator.elapsedMs(), sampledAtMs);
|
||||||
|
|||||||
+18
-7
@@ -722,19 +722,30 @@ public final class BukkitWorldReconciler {
|
|||||||
return WorldIdentity.resolve(worldKey);
|
return WorldIdentity.resolve(worldKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A world Iris cannot resolve is refused like one CraftServer asked for, with the banner and operator notice,
|
||||||
|
* then fails this load only.
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public CompletableFuture<World> createWorld(NamespacedKey worldKey, String dimension, Long seed) {
|
public CompletableFuture<World> createWorld(NamespacedKey worldKey, String dimension, Long seed) {
|
||||||
|
String configuredWorldName = worldKey.toString();
|
||||||
|
ChunkGenerator generator;
|
||||||
|
IrisDimension irisDimension;
|
||||||
try {
|
try {
|
||||||
String logicalWorldName = IrisWorldStorage.logicalName(worldKey);
|
configuredWorldName = configuredWorldName(worldKey);
|
||||||
String configuredWorldName = configuredWorldName(worldKey);
|
Iris.info("Loading World: %s | Generator: %s", IrisWorldStorage.logicalName(worldKey), dimension);
|
||||||
Iris.info("Loading World: %s | Generator: %s", logicalWorldName, dimension);
|
generator = plugin.requireWorldGenerator(configuredWorldName, dimension);
|
||||||
ChunkGenerator generator = plugin.getDefaultWorldGenerator(configuredWorldName, dimension);
|
irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension);
|
||||||
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension);
|
|
||||||
if (generator == null || irisDimension == null) {
|
if (generator == null || irisDimension == null) {
|
||||||
throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\".");
|
throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\".");
|
||||||
}
|
}
|
||||||
|
} catch (Throwable refusal) {
|
||||||
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + logicalWorldName + " using Iris:" + dimension + "...");
|
plugin.worldRefusals().report(configuredWorldName, worldKey, refusal);
|
||||||
|
return CompletableFuture.failedFuture(refusal);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + IrisWorldStorage.logicalName(worldKey)
|
||||||
|
+ " using Iris:" + dimension + "...");
|
||||||
WorldCreator creator = WorldCreatorCompat.ofPersistentKey(worldKey)
|
WorldCreator creator = WorldCreatorCompat.ofPersistentKey(worldKey)
|
||||||
.generator(generator)
|
.generator(generator)
|
||||||
.environment(BukkitEnvironment.from(irisDimension.getEnvironment()));
|
.environment(BukkitEnvironment.from(irisDimension.getEnvironment()));
|
||||||
|
|||||||
+15
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
package art.arcane.iris.world;
|
package art.arcane.iris.world;
|
||||||
|
|
||||||
|
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||||
import org.bukkit.HeightMap;
|
import org.bukkit.HeightMap;
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
import org.bukkit.World;
|
import org.bukkit.World;
|
||||||
@@ -48,6 +49,20 @@ final class IrisFailClosedChunkGenerator extends ChunkGenerator {
|
|||||||
+ "' remains locked: " + denialReason);
|
+ "' remains locked: " + denialReason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built on the way back to CraftServer, which turns any throw into the vanilla generator, so a cause that cannot
|
||||||
|
* be summarized is named by its type instead.
|
||||||
|
*/
|
||||||
|
static IrisFailClosedChunkGenerator refused(String worldName, Throwable failure) {
|
||||||
|
String reason;
|
||||||
|
try {
|
||||||
|
reason = GenerationRefusalNotice.summary(failure);
|
||||||
|
} catch (Throwable unreadable) {
|
||||||
|
reason = failure.getClass().getName();
|
||||||
|
}
|
||||||
|
return new IrisFailClosedChunkGenerator("Iris refuses to generate '" + worldName + "': " + reason);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void generateNoise(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull ChunkData chunkData) {
|
public void generateNoise(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull ChunkData chunkData) {
|
||||||
throw refusal();
|
throw refusal();
|
||||||
|
|||||||
+97
-18
@@ -18,8 +18,9 @@
|
|||||||
|
|
||||||
package art.arcane.iris.world;
|
package art.arcane.iris.world;
|
||||||
|
|
||||||
|
import art.arcane.iris.pack.PackFingerprints;
|
||||||
|
|
||||||
import art.arcane.iris.configuration.IrisSettings;
|
import art.arcane.iris.configuration.IrisSettings;
|
||||||
import art.arcane.iris.pack.datapack.ServerConfigurator;
|
|
||||||
|
|
||||||
import art.arcane.iris.Iris;
|
import art.arcane.iris.Iris;
|
||||||
import art.arcane.iris.world.lifecycle.WorldLifecycleStaging;
|
import art.arcane.iris.world.lifecycle.WorldLifecycleStaging;
|
||||||
@@ -73,6 +74,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pack validation, dimension lookup, and the world generator / biome provider resolution that the
|
* Pack validation, dimension lookup, and the world generator / biome provider resolution that the
|
||||||
@@ -90,6 +92,7 @@ public final class IrisWorldGeneratorResolver {
|
|||||||
private final AtomicBoolean externalContentRefreshQueued = new AtomicBoolean();
|
private final AtomicBoolean externalContentRefreshQueued = new AtomicBoolean();
|
||||||
private final AtomicBoolean externalContentRefreshRequested = new AtomicBoolean();
|
private final AtomicBoolean externalContentRefreshRequested = new AtomicBoolean();
|
||||||
private final Map<Path, PendingSnapshot> pendingSnapshots = new ConcurrentHashMap<>();
|
private final Map<Path, PendingSnapshot> pendingSnapshots = new ConcurrentHashMap<>();
|
||||||
|
private final WorldRefusalReporter refusals = new WorldRefusalReporter();
|
||||||
|
|
||||||
public IrisWorldGeneratorResolver(VolmitPlugin plugin) {
|
public IrisWorldGeneratorResolver(VolmitPlugin plugin) {
|
||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
@@ -182,12 +185,12 @@ public final class IrisWorldGeneratorResolver {
|
|||||||
}
|
}
|
||||||
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
|
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
|
||||||
Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath();
|
Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath();
|
||||||
ServerConfigurator.PackContentSnapshot contentSnapshot =
|
PackFingerprints.PackContentSnapshot contentSnapshot =
|
||||||
new ServerConfigurator.PackContentSnapshot("", Map.of());
|
new PackFingerprints.PackContentSnapshot("", Map.of());
|
||||||
String contextFingerprint = "";
|
String contextFingerprint = "";
|
||||||
Optional<List<PackValidationResult>> cached = Optional.empty();
|
Optional<List<PackValidationResult>> cached = Optional.empty();
|
||||||
try {
|
try {
|
||||||
contentSnapshot = ServerConfigurator.computePackContentSnapshot(packsRoot);
|
contentSnapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
|
||||||
contextFingerprint = PackValidationCache.contextFingerprint();
|
contextFingerprint = PackValidationCache.contextFingerprint();
|
||||||
if (!externalContentChanged) {
|
if (!externalContentChanged) {
|
||||||
cached = PackValidationCache.load(
|
cached = PackValidationCache.load(
|
||||||
@@ -272,19 +275,19 @@ public final class IrisWorldGeneratorResolver {
|
|||||||
private static FreshValidation validateStablePacks(
|
private static FreshValidation validateStablePacks(
|
||||||
File packsRoot,
|
File packsRoot,
|
||||||
List<File> initialPackDirs,
|
List<File> initialPackDirs,
|
||||||
ServerConfigurator.PackContentSnapshot initialSnapshot
|
PackFingerprints.PackContentSnapshot initialSnapshot
|
||||||
) {
|
) {
|
||||||
List<File> packDirs = initialPackDirs;
|
List<File> packDirs = initialPackDirs;
|
||||||
ServerConfigurator.PackContentSnapshot before = initialSnapshot;
|
PackFingerprints.PackContentSnapshot before = initialSnapshot;
|
||||||
for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) {
|
for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) {
|
||||||
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
|
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
|
||||||
List<PackValidationResult> results = validatePacks(packDirs);
|
List<PackValidationResult> results = validatePacks(packDirs);
|
||||||
ServerConfigurator.PackContentSnapshot after;
|
PackFingerprints.PackContentSnapshot after;
|
||||||
try {
|
try {
|
||||||
after = ServerConfigurator.computePackContentSnapshot(packsRoot);
|
after = PackFingerprints.computePackContentSnapshot(packsRoot);
|
||||||
} catch (RuntimeException exception) {
|
} catch (RuntimeException exception) {
|
||||||
Iris.reportError("Could not verify Iris pack bytes after validation", exception);
|
Iris.reportError("Could not verify Iris pack bytes after validation", exception);
|
||||||
after = new ServerConfigurator.PackContentSnapshot("", Map.of());
|
after = new PackFingerprints.PackContentSnapshot("", Map.of());
|
||||||
}
|
}
|
||||||
List<File> afterPackDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
|
List<File> afterPackDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
|
||||||
List<String> afterPackNames = afterPackDirs.stream().map(File::getName).sorted().toList();
|
List<String> afterPackNames = afterPackDirs.stream().map(File::getName).sorted().toList();
|
||||||
@@ -480,12 +483,28 @@ public final class IrisWorldGeneratorResolver {
|
|||||||
return fallback.get();
|
return fallback.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The generator CraftServer asks for. Paper turns a throw or a null here into the vanilla generator, so every
|
||||||
|
* request aimed at an Iris world gets a generator back: the Iris one, or a fail-closed one that refuses to let
|
||||||
|
* the level be created. Only the PlotSquared discovery sentinel, which is never created, gets null.
|
||||||
|
*/
|
||||||
@Nullable
|
@Nullable
|
||||||
public ChunkGenerator resolveDefaultWorldGenerator(String worldName, @Nullable String id) {
|
public ChunkGenerator resolveDefaultWorldGenerator(String worldName, @Nullable String id) {
|
||||||
if (isPlotSquaredGeneratorDiscoveryProbe(worldName, id)) {
|
if (isPlotSquaredGeneratorDiscoveryProbe(worldName, id)) {
|
||||||
Iris.debug("Ignoring PlotSquared generator discovery probe");
|
Iris.debug("Ignoring PlotSquared generator discovery probe");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
return requireWorldGenerator(worldName, id);
|
||||||
|
} catch (Throwable failure) {
|
||||||
|
return refuseWorld(worldName, failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolution for Iris' own loads, which report a failure per world instead of handing it to CraftServer.
|
||||||
|
*/
|
||||||
|
public ChunkGenerator requireWorldGenerator(String worldName, @Nullable String id) {
|
||||||
if (isGeneratorDiscoveryProbe(worldName, id)) {
|
if (isGeneratorDiscoveryProbe(worldName, id)) {
|
||||||
Iris.debug("Generator discovery probe for loaded world " + worldName);
|
Iris.debug("Generator discovery probe for loaded world " + worldName);
|
||||||
return IrisFailClosedChunkGenerator.discoveryProbe(worldName);
|
return IrisFailClosedChunkGenerator.discoveryProbe(worldName);
|
||||||
@@ -505,21 +524,37 @@ public final class IrisWorldGeneratorResolver {
|
|||||||
return startupLock;
|
return startupLock;
|
||||||
}
|
}
|
||||||
Iris.debug("Default World Generator Called for " + worldName + " using ID: " + id);
|
Iris.debug("Default World Generator Called for " + worldName + " using ID: " + id);
|
||||||
if (id == null || id.isEmpty()) id = IrisSettings.get().getGenerator().getDefaultWorldType();
|
String dimension = id == null || id.isEmpty() ? IrisSettings.get().getGenerator().getDefaultWorldType() : id;
|
||||||
Iris.debug("Generator ID: " + id + " requested by bukkit/plugin");
|
Iris.debug("Generator ID: " + dimension + " requested by bukkit/plugin");
|
||||||
|
|
||||||
File levelRoot = IrisWorldStorage.levelRoot();
|
File levelRoot = IrisWorldStorage.levelRoot();
|
||||||
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot);
|
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot);
|
||||||
requireWorldKeyAvailable(worldName, worldKey);
|
requireWorldKeyAvailable(worldName, worldKey);
|
||||||
requireOwnedWorld(worldName, levelRoot, worldKey);
|
requireOwnedWorld(worldName, levelRoot, worldKey);
|
||||||
|
return resolveFrozenWorldGenerator(worldName, dimension);
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorldRefusalReporter refusals() {
|
||||||
|
return refusals;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChunkGenerator refuseWorld(String worldName, Throwable failure) {
|
||||||
|
refusals.report(worldName, refusalKey(worldName), failure);
|
||||||
try {
|
try {
|
||||||
return resolveFrozenWorldGenerator(worldName, id);
|
Iris.reportError(failure);
|
||||||
} catch (RuntimeException failure) {
|
} catch (Throwable loggingFailure) {
|
||||||
Iris.reportError("Refusing to load configured Iris world '" + worldName
|
System.err.println("[Iris] Could not log the refusal of '" + worldName + "': "
|
||||||
+ "' because its frozen world-local pack snapshot could not be used.", failure);
|
+ loggingFailure.getClass().getName());
|
||||||
Bukkit.shutdown();
|
}
|
||||||
throw failure;
|
return IrisFailClosedChunkGenerator.refused(worldName, failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static NamespacedKey refusalKey(String worldName) {
|
||||||
|
try {
|
||||||
|
return messageWorldKey(worldName, IrisWorldStorage.levelRoot().getName());
|
||||||
|
} catch (Throwable unresolved) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,12 +623,56 @@ public final class IrisWorldGeneratorResolver {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName());
|
NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName());
|
||||||
|
if (!IRIS_DIMENSION_NAMESPACE.equals(messageKey.getNamespace())) {
|
||||||
|
if (dimensionRoot != null && IrisWorldStorage.holdsIrisContent(dimensionRoot.toPath())) {
|
||||||
|
throw new IllegalStateException("'" + worldName + "' (" + messageKey + ") is an Iris world whose"
|
||||||
|
+ " generation history is missing from "
|
||||||
|
+ GenerationHistoryPaths.forDimension(dimensionRoot.toPath()).generationRoot()
|
||||||
|
+ ", so Iris cannot generate it. Restore that folder from a backup. Keep worlds." + worldName
|
||||||
|
+ ".generator in bukkit.yml: without it the server generates vanilla terrain into this world.");
|
||||||
|
}
|
||||||
|
// Only bukkit.yml binds a vanilla slot at startup, and the refusal stops startup, so no command can run
|
||||||
|
// until that binding is gone.
|
||||||
|
throw new IllegalStateException("'" + worldName + "' (" + messageKey
|
||||||
|
+ ") is a vanilla world slot with no Iris world storage, so Iris cannot generate it."
|
||||||
|
+ " Remove worlds." + worldName + ".generator from bukkit.yml"
|
||||||
|
+ leftoverLevelDataRemoval(levelRoot)
|
||||||
|
+ ", start the server, then run /iris replace " + messageKey
|
||||||
|
+ " type=<pack> to replace it with Iris on the next restart.");
|
||||||
|
}
|
||||||
throw new IllegalStateException("'" + worldName + "' (" + messageKey
|
throw new IllegalStateException("'" + worldName + "' (" + messageKey
|
||||||
+ ") has no Iris world storage, so Iris cannot generate it."
|
+ ") has no Iris world storage, so Iris cannot generate it."
|
||||||
+ " Create Iris worlds with /iris create " + IrisWorldStorage.logicalName(messageKey)
|
+ " Create Iris worlds with /iris create " + IrisWorldStorage.logicalName(messageKey)
|
||||||
+ " type=<pack>; Iris registers them with Multiverse itself.");
|
+ " type=<pack>; Iris registers them with Multiverse itself.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A start refused before the level's overworld existed still leaves level.dat behind, and vanilla cannot start
|
||||||
|
* from a level.dat without world generation settings. A level that holds no chunks loses nothing to its removal.
|
||||||
|
*/
|
||||||
|
private static String leftoverLevelDataRemoval(File levelRoot) {
|
||||||
|
Path level = levelRoot.toPath();
|
||||||
|
Path overworld = level.resolve("dimensions").resolve("minecraft").resolve("overworld");
|
||||||
|
if (Files.exists(overworld.resolve("data").resolve("minecraft").resolve("world_gen_settings.dat"),
|
||||||
|
LinkOption.NOFOLLOW_LINKS)
|
||||||
|
|| hasEntries(overworld.resolve("region"))
|
||||||
|
|| hasEntries(level.resolve("region"))) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return " and delete " + level.resolve("level.dat") + " (this level has no chunks and was never created)";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasEntries(Path directory) {
|
||||||
|
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try (Stream<Path> entries = Files.list(directory)) {
|
||||||
|
return entries.findAny().isPresent();
|
||||||
|
} catch (IOException unreadable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean hasGenerationStorage(File dimensionRoot) {
|
private static boolean hasGenerationStorage(File dimensionRoot) {
|
||||||
GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(dimensionRoot.toPath());
|
GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(dimensionRoot.toPath());
|
||||||
return Files.isDirectory(paths.generationRoot(), LinkOption.NOFOLLOW_LINKS)
|
return Files.isDirectory(paths.generationRoot(), LinkOption.NOFOLLOW_LINKS)
|
||||||
@@ -845,7 +924,7 @@ public final class IrisWorldGeneratorResolver {
|
|||||||
|
|
||||||
private record FreshValidation(
|
private record FreshValidation(
|
||||||
List<File> packDirs,
|
List<File> packDirs,
|
||||||
ServerConfigurator.PackContentSnapshot contentSnapshot,
|
PackFingerprints.PackContentSnapshot contentSnapshot,
|
||||||
List<PackValidationResult> results,
|
List<PackValidationResult> results,
|
||||||
boolean stable
|
boolean stable
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/*
|
||||||
|
* Iris is a World Generator for Minecraft Servers
|
||||||
|
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||||
|
*
|
||||||
|
* 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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package art.arcane.iris.world;
|
||||||
|
|
||||||
|
import art.arcane.iris.Iris;
|
||||||
|
import art.arcane.iris.localization.C;
|
||||||
|
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||||
|
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.NamespacedKey;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.player.PlayerJoinEvent;
|
||||||
|
import org.bukkit.event.world.WorldLoadEvent;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentSkipListMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports Iris worlds that refused to generate: a console banner that says what the refusal does to the server, and
|
||||||
|
* an operator notice on join for as long as the world stays unloaded, since the banner scrolls away.
|
||||||
|
*/
|
||||||
|
public final class WorldRefusalReporter implements Listener {
|
||||||
|
private static final String UNRESOLVED_KEY = "unresolved key";
|
||||||
|
|
||||||
|
private final Map<String, String> operatorNotices = new ConcurrentSkipListMap<>();
|
||||||
|
private volatile boolean startupWorldsCreated;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iris enabled on a running server (a hotload) finds the startup worlds already loaded.
|
||||||
|
*/
|
||||||
|
public void attach(Plugin plugin) {
|
||||||
|
if (!Bukkit.getWorlds().isEmpty()) {
|
||||||
|
startupWorldsCreated = true;
|
||||||
|
}
|
||||||
|
Bukkit.getPluginManager().registerEvents(this, plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paper creates every startup level before it prepares any, and preparing one fires the first WorldLoadEvent.
|
||||||
|
* A generator asked for after that belongs to a runtime create, which fails only its caller.
|
||||||
|
*/
|
||||||
|
public boolean startupWorldsCreated() {
|
||||||
|
return startupWorldsCreated;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR)
|
||||||
|
public void onWorldLoad(WorldLoadEvent event) {
|
||||||
|
startupWorldsCreated = true;
|
||||||
|
try {
|
||||||
|
operatorNotices.remove(WorldIdentity.key(event.getWorld()).toString());
|
||||||
|
} catch (RuntimeException unkeyed) {
|
||||||
|
Iris.debug("Loaded world " + event.getWorld().getName() + " has no key to clear a refusal notice by");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR)
|
||||||
|
public void onPlayerJoin(PlayerJoinEvent event) {
|
||||||
|
if (operatorNotices.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
if (!player.isOp() && !player.hasPermission("iris.all")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (String notice : operatorNotices.values()) {
|
||||||
|
player.sendMessage(C.RED + notice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never throws: the caller is handing CraftServer a fail-closed generator or failing a load, and a reporting
|
||||||
|
* failure must not turn either into something else.
|
||||||
|
*/
|
||||||
|
public void report(String worldName, @Nullable NamespacedKey worldKey, Throwable failure) {
|
||||||
|
String key = worldKey == null ? UNRESOLVED_KEY : worldKey.toString();
|
||||||
|
try {
|
||||||
|
for (String line : notice(worldName, key, failure, startupWorldsCreated)) {
|
||||||
|
Iris.error(line);
|
||||||
|
}
|
||||||
|
operatorNotices.put(worldKey == null ? worldName : key, "Iris world '" + worldName + "' (" + key
|
||||||
|
+ ") did not load and refuses to generate: " + GenerationRefusalNotice.summary(failure));
|
||||||
|
} catch (Throwable reportingFailure) {
|
||||||
|
System.err.println("[Iris] Iris refused to generate world '" + worldName + "' (" + key
|
||||||
|
+ "); the refusal could not be reported: " + reportingFailure.getClass().getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> notice(String worldName, String worldKey, Throwable failure, boolean startupWorldsCreated) {
|
||||||
|
return GenerationRefusalNotice.compose(
|
||||||
|
"Iris refused to generate world '" + worldName + "' (" + worldKey + ")",
|
||||||
|
GenerationRefusalNotice.causes(failure),
|
||||||
|
List.of(
|
||||||
|
"Iris does not generate this world and does not let vanilla or any other generator"
|
||||||
|
+ " write it; no chunks are written.",
|
||||||
|
startupWorldsCreated
|
||||||
|
? "The world does not load; the server keeps running. Fix the cause, then load it again."
|
||||||
|
: "Server startup stops before this world loads. Fix the cause, then start the server again."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -189,7 +189,7 @@ public final class BukkitEnginePlatformHooks implements EnginePlatformHooks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean shouldBypassMantleStages(Engine engine) {
|
public boolean shouldRefuseGeneration(Engine engine) {
|
||||||
if (!J.isFolia() || !engine.getWorld().hasPlatformWorld()) {
|
if (!J.isFolia() || !engine.getWorld().hasPlatformWorld()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ public class TreeFellerSVC implements IrisService, IrisTreeFellerService {
|
|||||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||||
public void haltWhenSneakingStops(PlayerToggleSneakEvent event) {
|
public void haltWhenSneakingStops(PlayerToggleSneakEvent event) {
|
||||||
if (!event.isSneaking()) {
|
if (!event.isSneaking()) {
|
||||||
runner.finishRuns(event.getPlayer().getUniqueId());
|
runner.finishSneakRuns(event.getPlayer().getUniqueId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-4
@@ -523,10 +523,15 @@ final class TreeFellingRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean isRunControlActive(FellingRun run, Player player) {
|
private boolean isRunControlActive(FellingRun run, Player player) {
|
||||||
return player.isOnline()
|
try {
|
||||||
&& player.getGameMode() == GameMode.SURVIVAL
|
return player.isOnline()
|
||||||
&& player.isSneaking()
|
&& player.getGameMode() == GameMode.SURVIVAL
|
||||||
&& player.getWorld().equals(run.candidate.world());
|
&& (!run.runHooks.requiresSneaking() || player.isSneaking())
|
||||||
|
&& player.getWorld().equals(run.candidate.world());
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
IrisLogging.reportError("An Iris tree-feller integration control check failed.", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean reserveLogCost(FellingRun run) {
|
private boolean reserveLogCost(FellingRun run) {
|
||||||
@@ -622,6 +627,24 @@ final class TreeFellingRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void finishSneakRuns(UUID playerId) {
|
||||||
|
Set<FellingRun> runs = service.activeRuns.get(playerId);
|
||||||
|
if (runs == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (FellingRun run : List.copyOf(runs)) {
|
||||||
|
try {
|
||||||
|
if (!run.runHooks.requiresSneaking()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
IrisLogging.reportError("An Iris tree-feller integration control check failed.", error);
|
||||||
|
}
|
||||||
|
run.abortReason = "halted";
|
||||||
|
finish(run);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Map<ChunkPosition, List<TreeMarkerTraversal.Position>> groupByChunk(
|
private Map<ChunkPosition, List<TreeMarkerTraversal.Position>> groupByChunk(
|
||||||
List<TreeMarkerTraversal.Position> positions
|
List<TreeMarkerTraversal.Position> positions
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "Flat",
|
||||||
|
"derivative": "minecraft:plains",
|
||||||
|
"vanillaDerivative": "minecraft:plains",
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"minHeight": 1,
|
||||||
|
"maxHeight": 1,
|
||||||
|
"palette": [
|
||||||
|
{
|
||||||
|
"block": "minecraft:lime_wool"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"minHeight": 384,
|
||||||
|
"maxHeight": 384,
|
||||||
|
"palette": [
|
||||||
|
{
|
||||||
|
"block": "minecraft:green_concrete"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"generators": [
|
||||||
|
{
|
||||||
|
"generator": "flat",
|
||||||
|
"min": 96,
|
||||||
|
"max": 96
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"decorators": [
|
||||||
|
{
|
||||||
|
"palette": [
|
||||||
|
{
|
||||||
|
"block": "minecraft:oak_stairs",
|
||||||
|
"data": {
|
||||||
|
"half": "bottom",
|
||||||
|
"facing": "north"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"chance": 1,
|
||||||
|
"forcePlace": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "Decorator Fixture",
|
||||||
|
"version": 1,
|
||||||
|
"mode": {
|
||||||
|
"type": "OVERWORLD"
|
||||||
|
},
|
||||||
|
"regions": [
|
||||||
|
"flat"
|
||||||
|
],
|
||||||
|
"focusRegion": "flat",
|
||||||
|
"focus": "flat",
|
||||||
|
"landChance": 1,
|
||||||
|
"environment": "NORMAL",
|
||||||
|
"dimensionHeight": {
|
||||||
|
"min": -64,
|
||||||
|
"max": 320
|
||||||
|
},
|
||||||
|
"logicalHeight": 384,
|
||||||
|
"fluidHeight": 0,
|
||||||
|
"bedrock": false,
|
||||||
|
"rockPalette": {
|
||||||
|
"palette": [
|
||||||
|
{
|
||||||
|
"block": "minecraft:red_concrete"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"interpolator": {
|
||||||
|
"function": "NONE",
|
||||||
|
"horizontalScale": 1
|
||||||
|
},
|
||||||
|
"seed": 310,
|
||||||
|
"composite": [
|
||||||
|
{
|
||||||
|
"seed": 310,
|
||||||
|
"style": {
|
||||||
|
"style": "FLAT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "Flat",
|
||||||
|
"landBiomes": [
|
||||||
|
"flat"
|
||||||
|
],
|
||||||
|
"seaBiomes": [
|
||||||
|
"flat"
|
||||||
|
],
|
||||||
|
"shoreBiomes": [
|
||||||
|
"flat"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'iris-decorators',
|
||||||
|
description: 'Verify authored stair decorations occupy one block and retain their half state.',
|
||||||
|
async run(context) {
|
||||||
|
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
|
||||||
|
context.expect(/^isolated=true\s*$/m.test(source), 'Decorator generation requires an isolated instance')
|
||||||
|
await context.step('create a world with authored stair decorations', async () => {
|
||||||
|
const response = await context.command('/iris create name=decorator_probe type=decorator-fixture seed=78264193',
|
||||||
|
/Successfully created your world|Failed to create/, 180000)
|
||||||
|
context.expect(response.includes('Successfully created your world'), response)
|
||||||
|
})
|
||||||
|
await context.step('client sees single bottom-half stair decorations', async () => {
|
||||||
|
let checked = 0
|
||||||
|
await context.waitUntil(() => {
|
||||||
|
checked = 0
|
||||||
|
const center = context.bot.entity.position.floored()
|
||||||
|
for (let x = -6; x <= 6; x++) {
|
||||||
|
for (let z = -6; z <= 6; z++) {
|
||||||
|
for (let y = -4; y <= 1; y++) {
|
||||||
|
const position = center.offset(x, y, z)
|
||||||
|
if (context.bot.blockAt(position)?.name !== 'lime_wool') continue
|
||||||
|
const decoration = context.bot.blockAt(position.offset(0, 1, 0))
|
||||||
|
const above = context.bot.blockAt(position.offset(0, 2, 0))
|
||||||
|
if (!decoration || !above) return false
|
||||||
|
if (decoration.name !== 'oak_stairs' || decoration.getProperties().half !== 'bottom'
|
||||||
|
|| above.name !== 'air') return false
|
||||||
|
checked++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return checked >= 100
|
||||||
|
}, { label: 'At least 100 authored stair columns with empty space above', timeoutMs: 60000, intervalMs: 250 })
|
||||||
|
context.expect(checked >= 100, 'Client verified generated decorator columns', { checked })
|
||||||
|
context.report.decoratorColumns = checked
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
-1
@@ -4,6 +4,13 @@ import art.arcane.volmlib.nativelib.NativeAdapters;
|
|||||||
import art.arcane.iris.world.runtime.RuntimeInjection;
|
import art.arcane.iris.world.runtime.RuntimeInjection;
|
||||||
import art.arcane.iris.world.IrisCreator;
|
import art.arcane.iris.world.IrisCreator;
|
||||||
import art.arcane.iris.world.IrisToolbelt;
|
import art.arcane.iris.world.IrisToolbelt;
|
||||||
|
import art.arcane.iris.world.IrisWorldStorage;
|
||||||
|
import art.arcane.iris.world.WorldCreatorCompat;
|
||||||
|
import art.arcane.iris.world.lifecycle.LifecycleOperationCoordinator;
|
||||||
|
import art.arcane.iris.platform.bootstrap.ServerProperties;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.configuration.file.YamlConfiguration;
|
||||||
import org.bukkit.Material;
|
import org.bukkit.Material;
|
||||||
import org.bukkit.block.Biome;
|
import org.bukkit.block.Biome;
|
||||||
import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess;
|
import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess;
|
||||||
@@ -19,6 +26,10 @@ import org.bukkit.command.CommandSender;
|
|||||||
import org.bukkit.generator.ChunkGenerator;
|
import org.bukkit.generator.ChunkGenerator;
|
||||||
import org.bukkit.plugin.java.JavaPlugin;
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.LinkOption;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
@@ -26,6 +37,7 @@ import java.util.logging.Level;
|
|||||||
public final class NativeLifecycleFixture extends JavaPlugin implements WorldRuntimeExecution {
|
public final class NativeLifecycleFixture extends JavaPlugin implements WorldRuntimeExecution {
|
||||||
private static final NamespacedKey WORLD_KEY = NamespacedKey.minecraft("native_lifecycle_probe");
|
private static final NamespacedKey WORLD_KEY = NamespacedKey.minecraft("native_lifecycle_probe");
|
||||||
private static final NamespacedKey TERRAIN_KEY = new NamespacedKey("iris", "native_terrain_probe");
|
private static final NamespacedKey TERRAIN_KEY = new NamespacedKey("iris", "native_terrain_probe");
|
||||||
|
private static final NamespacedKey COMMAND_KEY = new NamespacedKey("iris", "lifecycle_probe");
|
||||||
private static final long WORLD_SEED = 78264193L;
|
private static final long WORLD_SEED = 78264193L;
|
||||||
private NativeWorldRuntime runtime;
|
private NativeWorldRuntime runtime;
|
||||||
|
|
||||||
@@ -45,7 +57,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
|||||||
@Override
|
@Override
|
||||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||||
if (arguments.length != 1) {
|
if (arguments.length != 1) {
|
||||||
sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload");
|
sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload|lifecycle-status|lifecycle-entry");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -56,6 +68,8 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
|||||||
case "terrain-create" -> createTerrain(sender);
|
case "terrain-create" -> createTerrain(sender);
|
||||||
case "terrain-check" -> checkTerrain(sender);
|
case "terrain-check" -> checkTerrain(sender);
|
||||||
case "terrain-unload" -> unloadTerrain(sender);
|
case "terrain-unload" -> unloadTerrain(sender);
|
||||||
|
case "lifecycle-status" -> lifecycleStatus(sender);
|
||||||
|
case "lifecycle-entry" -> lifecycleEntry(sender);
|
||||||
case "status" -> sender.sendMessage("NATIVE_STATUS available=" + runtime.available()
|
case "status" -> sender.sendMessage("NATIVE_STATUS available=" + runtime.available()
|
||||||
+ " loaded=" + (Bukkit.getWorld(WORLD_KEY) != null));
|
+ " loaded=" + (Bukkit.getWorld(WORLD_KEY) != null));
|
||||||
default -> throw new IllegalArgumentException("Unknown lifecycle operation");
|
default -> throw new IllegalArgumentException("Unknown lifecycle operation");
|
||||||
@@ -66,6 +80,61 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void lifecycleStatus(CommandSender sender) throws Exception {
|
||||||
|
World world = Bukkit.getWorld(COMMAND_KEY);
|
||||||
|
Path storage = WorldCreatorCompat.persistentLevelRoot(COMMAND_KEY).toPath();
|
||||||
|
Path levelRoot = IrisWorldStorage.levelRoot().toPath();
|
||||||
|
Path registry = levelRoot.resolve("iris/worlds.json");
|
||||||
|
boolean registered = Files.exists(registry)
|
||||||
|
&& Files.readString(registry).contains("\"" + COMMAND_KEY + "\"");
|
||||||
|
String startupName = IrisWorldStorage.configuredWorldName(COMMAND_KEY, levelRoot.getFileName().toString());
|
||||||
|
YamlConfiguration configuration = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML);
|
||||||
|
boolean configured = configuration.getConfigurationSection("worlds") != null
|
||||||
|
&& configuration.getConfigurationSection("worlds").contains(startupName);
|
||||||
|
File quarantineRoot = levelRoot.resolve("dimensions/iris").toFile();
|
||||||
|
File[] quarantines = quarantineRoot.listFiles((directory, name) -> name.startsWith(".iris-delete-"));
|
||||||
|
int quarantineCount = quarantines == null ? 0 : quarantines.length;
|
||||||
|
Path queueFile = Bukkit.getPluginManager().getPlugin("Iris").getDataFolder().toPath()
|
||||||
|
.resolve("pending-world-deletes.txt");
|
||||||
|
List<String> queue = Files.exists(queueFile) ? Files.readAllLines(queueFile) : List.of();
|
||||||
|
boolean queued = quarantineCount > 0;
|
||||||
|
if (quarantines != null) {
|
||||||
|
for (File quarantine : quarantines) {
|
||||||
|
queued &= queue.contains(quarantine.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boolean disk = Files.exists(storage, LinkOption.NOFOLLOW_LINKS);
|
||||||
|
String cleanup = disk ? "present" : quarantineCount == 0 ? "deleted" : queued ? "queued" : "unsafe";
|
||||||
|
boolean idle = true;
|
||||||
|
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
|
||||||
|
for (LifecycleOperationCoordinator.Domain domain : LifecycleOperationCoordinator.Domain.values()) {
|
||||||
|
idle &= coordinator.active(domain).isEmpty();
|
||||||
|
}
|
||||||
|
sender.sendMessage("IRIS_LIFECYCLE loaded=" + (world != null) + " registered=" + registered
|
||||||
|
+ " configured=" + configured + " disk=" + disk + " quarantines=" + quarantineCount
|
||||||
|
+ " cleanup=" + cleanup + " idle=" + idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void lifecycleEntry(CommandSender sender) {
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
throw new IllegalArgumentException("Lifecycle entry requires a player");
|
||||||
|
}
|
||||||
|
World world = Bukkit.getWorld(COMMAND_KEY);
|
||||||
|
if (world == null || player.getWorld() != world || world.getSeed() != WORLD_SEED
|
||||||
|
|| !IrisToolbelt.isIrisWorld(world)) {
|
||||||
|
throw new IllegalStateException("Player did not enter the requested seeded Iris world");
|
||||||
|
}
|
||||||
|
Location feet = player.getLocation();
|
||||||
|
Material floor = world.getBlockAt(feet.getBlockX(), feet.getBlockY() - 1, feet.getBlockZ()).getType();
|
||||||
|
if (floor != Material.LIME_WOOL || !floor.isSolid()
|
||||||
|
|| !world.getBlockAt(feet.getBlockX(), feet.getBlockY(), feet.getBlockZ()).isPassable()
|
||||||
|
|| !world.getBlockAt(feet.getBlockX(), feet.getBlockY() + 1, feet.getBlockZ()).isPassable()) {
|
||||||
|
throw new IllegalStateException("Lifecycle player entry lacks a safe fixture floor");
|
||||||
|
}
|
||||||
|
sender.sendMessage("IRIS_LIFECYCLE_ENTRY world=" + COMMAND_KEY + " seed=" + world.getSeed()
|
||||||
|
+ " floor=lime_wool safe=true");
|
||||||
|
}
|
||||||
|
|
||||||
private void create(CommandSender sender) throws ReflectiveOperationException {
|
private void create(CommandSender sender) throws ReflectiveOperationException {
|
||||||
if (!runtime.available() || Bukkit.getWorld(WORLD_KEY) != null) {
|
if (!runtime.available() || Bukkit.getWorld(WORLD_KEY) != null) {
|
||||||
throw new IllegalStateException("Native runtime unavailable or probe world already loaded");
|
throw new IllegalStateException("Native runtime unavailable or probe world already loaded");
|
||||||
@@ -124,6 +193,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
|||||||
int y = world.getHighestBlockYAt(x, z);
|
int y = world.getHighestBlockYAt(x, z);
|
||||||
if (y < 90 || y > 100 || world.getBlockAt(x, y, z).getType() != Material.LIME_WOOL
|
if (y < 90 || y > 100 || world.getBlockAt(x, y, z).getType() != Material.LIME_WOOL
|
||||||
|| world.getBlockAt(x, y - 3, z).getType() != Material.GREEN_CONCRETE
|
|| world.getBlockAt(x, y - 3, z).getType() != Material.GREEN_CONCRETE
|
||||||
|
|| world.getBlockAt(x, world.getMinHeight(), z).getType() != Material.GREEN_CONCRETE
|
||||||
|| world.getBiome(x, y, z) != Biome.PLAINS) {
|
|| world.getBiome(x, y, z) != Biome.PLAINS) {
|
||||||
throw new IllegalStateException("Terrain column differs at " + x + "," + y + "," + z
|
throw new IllegalStateException("Terrain column differs at " + x + "," + y + "," + z
|
||||||
+ ": " + world.getBlockAt(x, y, z).getType());
|
+ ": " + world.getBlockAt(x, y, z).getType());
|
||||||
|
|||||||
@@ -11,6 +11,15 @@
|
|||||||
"block": "minecraft:lime_wool"
|
"block": "minecraft:lime_wool"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"minHeight": 384,
|
||||||
|
"maxHeight": 384,
|
||||||
|
"palette": [
|
||||||
|
{
|
||||||
|
"block": "minecraft:green_concrete"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"generators": [
|
"generators": [
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
"rockPalette": {
|
"rockPalette": {
|
||||||
"palette": [
|
"palette": [
|
||||||
{
|
{
|
||||||
"block": "minecraft:green_concrete"
|
"block": "minecraft:red_concrete"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java'
|
||||||
|
}
|
||||||
|
|
||||||
|
version = '1.0.0'
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
maven { url = 'https://repo.papermc.io/repository/maven-public/' }
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnly files(providers.gradleProperty('irisJar').get())
|
||||||
|
compileOnly files(providers.gradleProperty('irisGsonJar').get())
|
||||||
|
compileOnly 'io.papermc.paper:paper-api:26.1.2.build.74-stable'
|
||||||
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain.languageVersion = JavaLanguageVersion.of(25)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
main {
|
||||||
|
java.srcDirs = ['src']
|
||||||
|
resources.srcDirs = ['resources']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(JavaCompile).configureEach {
|
||||||
|
options.compilerArgs += '-parameters'
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named('jar') {
|
||||||
|
archiveBaseName = 'IrisNaturalSpawningFixture'
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
name: IrisNaturalSpawningFixture
|
||||||
|
version: 1.0.0
|
||||||
|
main: art.arcane.iris.fixture.NaturalSpawningFixture
|
||||||
|
api-version: '26.1.2'
|
||||||
|
folia-supported: true
|
||||||
|
depend: [Iris]
|
||||||
|
commands:
|
||||||
|
irisspawnqa:
|
||||||
|
permission: irisspawnqa.run
|
||||||
|
permissions:
|
||||||
|
irisspawnqa.run:
|
||||||
|
default: op
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
rootProject.name = 'iris-natural-spawning-fixture'
|
||||||
+701
@@ -0,0 +1,701 @@
|
|||||||
|
package art.arcane.iris.fixture;
|
||||||
|
|
||||||
|
import art.arcane.iris.configuration.IrisSettings;
|
||||||
|
import art.arcane.iris.generation.biome.IrisBiome;
|
||||||
|
import art.arcane.iris.generation.runtime.BiomeEnvironment;
|
||||||
|
import art.arcane.iris.generation.runtime.Engine;
|
||||||
|
import art.arcane.iris.world.IrisCreator;
|
||||||
|
import art.arcane.iris.world.entity.IrisSpawner;
|
||||||
|
import art.arcane.iris.world.entity.IrisMarker;
|
||||||
|
import art.arcane.iris.world.entity.EntitySpawnSeed;
|
||||||
|
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
|
||||||
|
import art.arcane.iris.pack.value.IrisPosition;
|
||||||
|
import art.arcane.volmlib.util.math.RNG;
|
||||||
|
import art.arcane.iris.world.entity.IrisEntitySpawn;
|
||||||
|
import art.arcane.iris.world.entity.IrisEntity;
|
||||||
|
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||||
|
import art.arcane.iris.platform.bukkit.BukkitEntityType;
|
||||||
|
import art.arcane.iris.world.IrisToolbelt;
|
||||||
|
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Chunk;
|
||||||
|
import org.bukkit.Difficulty;
|
||||||
|
import org.bukkit.GameMode;
|
||||||
|
import org.bukkit.HeightMap;
|
||||||
|
import org.bukkit.GameRules;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.NamespacedKey;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.entity.Entity;
|
||||||
|
import org.bukkit.entity.EntityType;
|
||||||
|
import org.bukkit.entity.LivingEntity;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.entity.CreatureSpawnEvent;
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.InvocationTargetException;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
|
||||||
|
public final class NaturalSpawningFixture extends JavaPlugin implements Listener {
|
||||||
|
private static final NamespacedKey WORLD_KEY = new NamespacedKey("iris", "spawn_qa");
|
||||||
|
private static final long WORLD_SEED = 78264193L;
|
||||||
|
private static final int FROG_CAPACITY = 16;
|
||||||
|
private static final int ZOMBIE_CAPACITY = 2;
|
||||||
|
private static final int BIOME_READY_ATTEMPTS = 60;
|
||||||
|
private static final long BIOME_RETRY_TICKS = 10L;
|
||||||
|
|
||||||
|
private boolean previousAmbient;
|
||||||
|
private boolean previousMarkers;
|
||||||
|
private boolean previousForcedPersistence;
|
||||||
|
private volatile boolean ready;
|
||||||
|
private int firstZombieBatch;
|
||||||
|
private int ambientEvents;
|
||||||
|
private int cancelledAmbientEvents;
|
||||||
|
private final ThreadLocal<Boolean> ambientInvocation = ThreadLocal.withInitial(() -> false);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onEnable() {
|
||||||
|
Bukkit.getPluginManager().registerEvents(this, this);
|
||||||
|
previousAmbient = IrisSettings.get().getWorld().isAmbientEntitySpawningSystem();
|
||||||
|
previousMarkers = IrisSettings.get().getWorld().isMarkerEntitySpawningSystem();
|
||||||
|
previousForcedPersistence = IrisSettings.get().getWorld().isForcePersistEntities();
|
||||||
|
IrisSettings.get().getWorld().setAmbientEntitySpawningSystem(false);
|
||||||
|
IrisSettings.get().getWorld().setMarkerEntitySpawningSystem(false);
|
||||||
|
IrisSettings.get().getWorld().setForcePersistEntities(false);
|
||||||
|
getLogger().info("Natural spawning fixture ready; commands use the Iris ambient path on the owning region.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisable() {
|
||||||
|
IrisSettings.get().getWorld().setAmbientEntitySpawningSystem(previousAmbient);
|
||||||
|
IrisSettings.get().getWorld().setMarkerEntitySpawningSystem(previousMarkers);
|
||||||
|
IrisSettings.get().getWorld().setForcePersistEntities(previousForcedPersistence);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||||
|
if (arguments.length != 1) {
|
||||||
|
notifySender(sender, "Use /irisspawnqa setup|frogs|zombies|stress|check|seeded|completion|surface|cave");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
switch (arguments[0]) {
|
||||||
|
case "setup" -> setup(sender);
|
||||||
|
case "frogs" -> runRegion(sender, () -> spawnFrogs(sender));
|
||||||
|
case "zombies" -> runRegion(sender, () -> spawnZombies(sender));
|
||||||
|
case "stress" -> runRegion(sender, () -> stress(sender));
|
||||||
|
case "check" -> runRegion(sender, () -> check(sender));
|
||||||
|
case "seeded" -> seeded(sender);
|
||||||
|
case "completion" -> completion(sender);
|
||||||
|
case "surface" -> view(sender, 67D);
|
||||||
|
case "cave" -> view(sender, -46D);
|
||||||
|
default -> throw new IllegalArgumentException("Unknown spawning operation");
|
||||||
|
}
|
||||||
|
} catch (Throwable failure) {
|
||||||
|
fail(sender, failure);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGHEST)
|
||||||
|
public void isolateSpawns(CreatureSpawnEvent event) {
|
||||||
|
if (!WORLD_KEY.equals(event.getLocation().getWorld().getKey())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ambientInvocation.get()) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ambientEvents++;
|
||||||
|
if (event.isCancelled()) {
|
||||||
|
cancelledAmbientEvents++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setup(CommandSender sender) {
|
||||||
|
if (!(sender instanceof Player)) {
|
||||||
|
throw new IllegalArgumentException("Setup requires a player");
|
||||||
|
}
|
||||||
|
World world = Bukkit.getWorld(WORLD_KEY);
|
||||||
|
if (world != null) {
|
||||||
|
require(IrisToolbelt.isIrisWorld(world) && world.getSeed() == WORLD_SEED,
|
||||||
|
"Existing fixture key is not the owned Iris world with the expected seed");
|
||||||
|
ready = false;
|
||||||
|
Bukkit.getGlobalRegionScheduler().execute(this, () -> configureWorld(sender, world));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ready = false;
|
||||||
|
Bukkit.getAsyncScheduler().runNow(this, task -> createWorld(sender));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createWorld(CommandSender sender) {
|
||||||
|
try {
|
||||||
|
World world = new IrisCreator().name(WORLD_KEY.toString()).dimension("spawn-qa")
|
||||||
|
.seed(WORLD_SEED).create();
|
||||||
|
Bukkit.getGlobalRegionScheduler().execute(this, () -> configureWorld(sender, world));
|
||||||
|
} catch (Throwable failure) {
|
||||||
|
fail(sender, failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureWorld(CommandSender sender, World world) {
|
||||||
|
try {
|
||||||
|
world.setDifficulty(Difficulty.NORMAL);
|
||||||
|
world.setTime(6000L);
|
||||||
|
world.setStorm(false);
|
||||||
|
world.setThundering(false);
|
||||||
|
world.setGameRule(GameRules.SPAWN_MOBS, true);
|
||||||
|
List<CompletableFuture<Void>> chunks = new ArrayList<>();
|
||||||
|
for (int chunkX = -1; chunkX <= 1; chunkX++) {
|
||||||
|
for (int chunkZ = -1; chunkZ <= 1; chunkZ++) {
|
||||||
|
chunks.add(retainChunk(world, chunkX, chunkZ));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CompletableFuture.allOf(chunks.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> {
|
||||||
|
if (failure != null) {
|
||||||
|
fail(sender, failure);
|
||||||
|
} else {
|
||||||
|
Bukkit.getRegionScheduler().execute(this, world, 0, 0, () -> prepareChunk(sender, world));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Throwable failure) {
|
||||||
|
fail(sender, failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<Void> retainChunk(World world, int chunkX, int chunkZ) {
|
||||||
|
CompletableFuture<Void> retained = new CompletableFuture<>();
|
||||||
|
world.getChunkAtAsync(chunkX, chunkZ, true).whenComplete((chunk, failure) -> {
|
||||||
|
if (failure != null) {
|
||||||
|
retained.completeExceptionally(failure);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Bukkit.getRegionScheduler().execute(this, world, chunkX, chunkZ, () -> {
|
||||||
|
try {
|
||||||
|
chunk.addPluginChunkTicket(this);
|
||||||
|
for (Entity entity : chunk.getEntities()) {
|
||||||
|
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
|
||||||
|
entity.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
retained.complete(null);
|
||||||
|
} catch (Throwable error) {
|
||||||
|
retained.completeExceptionally(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return retained;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void prepareChunk(CommandSender sender, World world) {
|
||||||
|
try {
|
||||||
|
require(WORLD_KEY.equals(world.getKey()) && IrisToolbelt.isIrisWorld(world)
|
||||||
|
&& world.getSeed() == WORLD_SEED, "Fixture world ownership changed");
|
||||||
|
Chunk chunk = world.getChunkAt(0, 0);
|
||||||
|
int removed = 0;
|
||||||
|
for (Entity entity : chunk.getEntities()) {
|
||||||
|
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
|
||||||
|
entity.remove();
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Entity entity : chunk.getEntities()) {
|
||||||
|
require(!(entity instanceof LivingEntity) || entity instanceof Player || !entity.isValid(),
|
||||||
|
"Fixture target chunk retained a living mob after cleanup: " + entity.getType());
|
||||||
|
}
|
||||||
|
getLogger().info("SPAWN_QA_CLEANUP removed=" + removed + " chunk=0,0");
|
||||||
|
firstZombieBatch = 0;
|
||||||
|
ambientEvents = 0;
|
||||||
|
cancelledAmbientEvents = 0;
|
||||||
|
for (int x = 0; x < 16; x++) {
|
||||||
|
for (int z = 0; z < 16; z++) {
|
||||||
|
chunk.getBlock(x, 64, z).setType(Material.GRASS_BLOCK, false);
|
||||||
|
for (int y = 65; y <= 68; y++) {
|
||||||
|
chunk.getBlock(x, y, z).setType(Material.AIR, false);
|
||||||
|
}
|
||||||
|
for (int y = -48; y <= -42; y++) {
|
||||||
|
boolean shell = y == -48 || y == -42 || x == 0 || x == 15 || z == 0 || z == 15;
|
||||||
|
chunk.getBlock(x, y, z).setType(shell ? Material.STONE : Material.CAVE_AIR, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!IrisToolbelt.isIrisWorld(world) || world.getSeed() != WORLD_SEED) {
|
||||||
|
throw new IllegalStateException("Fixture is not the requested Iris world");
|
||||||
|
}
|
||||||
|
awaitBiomeEnvironment(sender, world, 0);
|
||||||
|
} catch (Throwable failure) {
|
||||||
|
fail(sender, failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void awaitBiomeEnvironment(CommandSender sender, World world, int attempt) {
|
||||||
|
try {
|
||||||
|
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||||
|
engine.getSurfaceBiomeEnvironment(8, 8);
|
||||||
|
ready = true;
|
||||||
|
teleport(sender, world, 67D, "SPAWN_QA_SETUP world=" + WORLD_KEY + " seed=" + WORLD_SEED);
|
||||||
|
} catch (SavedBiomeUnavailableException unavailable) {
|
||||||
|
if (!unavailable.isLoading() || attempt >= BIOME_READY_ATTEMPTS) {
|
||||||
|
fail(sender, unavailable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Bukkit.getRegionScheduler().runDelayed(this, world, 0, 0,
|
||||||
|
task -> awaitBiomeEnvironment(sender, world, attempt + 1), BIOME_RETRY_TICKS);
|
||||||
|
} catch (Throwable failure) {
|
||||||
|
fail(sender, failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void completion(CommandSender sender) {
|
||||||
|
World world = requireWorld();
|
||||||
|
Bukkit.getGlobalRegionScheduler().execute(this, () -> {
|
||||||
|
world.setGameRule(GameRules.SPAWN_MOBS, false);
|
||||||
|
runRegion(sender, () -> beginCompletion(sender, world));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void beginCompletion(CommandSender sender, World world) {
|
||||||
|
try {
|
||||||
|
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||||
|
Object manager = engine.getWorldManager();
|
||||||
|
Field field = manager.getClass().getDeclaredField("chunkMaintenance");
|
||||||
|
field.setAccessible(true);
|
||||||
|
Object maintenance = field.get(manager);
|
||||||
|
Method run = maintenance.getClass().getDeclaredMethod("runInitialSpawn", World.class, int.class, int.class, Runnable.class);
|
||||||
|
run.setAccessible(true);
|
||||||
|
Field pending = maintenance.getClass().getDeclaredField("markerFlagQueue");
|
||||||
|
pending.setAccessible(true);
|
||||||
|
AtomicInteger callbacks = new AtomicInteger();
|
||||||
|
Runnable callback = () -> {
|
||||||
|
require(Bukkit.isOwnedByCurrentRegion(world, 0, 0), "Initial callback ran outside its owning region");
|
||||||
|
callbacks.incrementAndGet();
|
||||||
|
Chunk chunk = world.getChunkAt(0, 0);
|
||||||
|
invokeAmbient(runtimeSpawn(chunk, "seeded"), chunk, true);
|
||||||
|
};
|
||||||
|
run.invoke(maintenance, world, 0, 0, callback);
|
||||||
|
awaitSkippedInitial(sender, world, new CompletionRun(engine, maintenance, run, pending, callback, callbacks), 0);
|
||||||
|
} catch (ReflectiveOperationException failure) {
|
||||||
|
throw new IllegalStateException("Could not start initial completion test", failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void awaitSkippedInitial(CommandSender sender, World world, CompletionRun state, int attempt) {
|
||||||
|
Bukkit.getRegionScheduler().runDelayed(this, world, 0, 0, task -> {
|
||||||
|
try {
|
||||||
|
require(state.callbacks().get() == 0, "Disabled mob spawning consumed the initial pass");
|
||||||
|
if (!((Set<?>) state.pending().get(state.maintenance())).isEmpty()) {
|
||||||
|
require(attempt < 100, "Skipped initial pass did not release its pending state");
|
||||||
|
awaitSkippedInitial(sender, world, state, attempt + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Bukkit.getGlobalRegionScheduler().execute(this, () -> {
|
||||||
|
world.setGameRule(GameRules.SPAWN_MOBS, true);
|
||||||
|
runRegion(sender, () -> retryInitialCompletion(sender, world, state));
|
||||||
|
});
|
||||||
|
} catch (Throwable failure) {
|
||||||
|
fail(sender, failure);
|
||||||
|
}
|
||||||
|
}, 2L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void retryInitialCompletion(CommandSender sender, World world, CompletionRun state) {
|
||||||
|
try {
|
||||||
|
state.run().invoke(state.maintenance(), world, 0, 0, state.callback());
|
||||||
|
state.run().invoke(state.maintenance(), world, 0, 0, state.callback());
|
||||||
|
awaitInitialCompletion(sender, world, state, 0);
|
||||||
|
} catch (ReflectiveOperationException failure) {
|
||||||
|
throw new IllegalStateException("Could not retry initial spawning", failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void awaitInitialCompletion(CommandSender sender, World world, CompletionRun state, int attempt) {
|
||||||
|
Bukkit.getRegionScheduler().runDelayed(this, world, 0, 0, task -> {
|
||||||
|
try {
|
||||||
|
if (state.callbacks().get() == 0 || !((Set<?>) state.pending().get(state.maintenance())).isEmpty()) {
|
||||||
|
require(attempt < 100, "Initial spawning did not complete after retry");
|
||||||
|
awaitInitialCompletion(sender, world, state, attempt + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
require(state.callbacks().get() == 1, "Concurrent initial requests spawned duplicate populations");
|
||||||
|
state.run().invoke(state.maintenance(), world, 0, 0, state.callback());
|
||||||
|
Bukkit.getAsyncScheduler().runNow(this, ignored -> {
|
||||||
|
try {
|
||||||
|
require(state.engine().getMantle().getMantle().hasFlag(0, 0, MantleFlag.INITIAL_SPAWNED_MARKER), "Initial completion was not persisted");
|
||||||
|
require(state.callbacks().get() == 1, "Completed initial pass ran again");
|
||||||
|
evidence(sender, "SPAWN_QA_COMPLETION retry=true callbacks=1 persisted=true owned=true");
|
||||||
|
} catch (Throwable failure) {
|
||||||
|
fail(sender, failure);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Throwable failure) {
|
||||||
|
fail(sender, failure);
|
||||||
|
}
|
||||||
|
}, 2L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void seeded(CommandSender sender) {
|
||||||
|
requireWorld();
|
||||||
|
List<List<String>> baseline = new ArrayList<>();
|
||||||
|
sampleInitial(0, false).thenCompose(first -> {
|
||||||
|
baseline.add(first);
|
||||||
|
return sampleInitial(1, false);
|
||||||
|
}).thenCompose(second -> {
|
||||||
|
baseline.add(second);
|
||||||
|
return sampleInitial(1, false);
|
||||||
|
}).thenCompose(repeated -> {
|
||||||
|
require(baseline.get(1).equals(repeated), "Second chunk changed after reversed generation order");
|
||||||
|
return sampleInitial(0, false);
|
||||||
|
}).thenCompose(repeated -> {
|
||||||
|
require(baseline.get(0).equals(repeated), "First chunk changed after reversed generation order");
|
||||||
|
return sampleInitial(0, true);
|
||||||
|
}).thenCompose(marker -> {
|
||||||
|
baseline.add(marker);
|
||||||
|
return sampleInitial(1, true);
|
||||||
|
}).thenCompose(marker -> {
|
||||||
|
baseline.add(marker);
|
||||||
|
return sampleInitial(1, true);
|
||||||
|
}).thenCompose(repeated -> {
|
||||||
|
require(baseline.get(3).equals(repeated), "Second marker changed after reversed generation order");
|
||||||
|
return sampleInitial(0, true);
|
||||||
|
}).whenComplete((repeated, failure) -> {
|
||||||
|
if (failure != null) {
|
||||||
|
fail(sender, failure);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
require(baseline.get(2).equals(repeated), "First marker changed after reversed generation order");
|
||||||
|
evidence(sender, "SPAWN_QA_SEEDED chunks=2 markers=2 repeats=2 equipment=true attributes=true");
|
||||||
|
} catch (Throwable error) {
|
||||||
|
fail(sender, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<List<String>> sampleInitial(int chunkX, boolean marker) {
|
||||||
|
CompletableFuture<List<String>> result = new CompletableFuture<>();
|
||||||
|
World world = requireWorld();
|
||||||
|
Bukkit.getRegionScheduler().execute(this, world, chunkX, 0, () -> {
|
||||||
|
try {
|
||||||
|
Chunk chunk = world.getChunkAt(chunkX, 0);
|
||||||
|
for (Entity entity : chunk.getEntities()) {
|
||||||
|
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
|
||||||
|
entity.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int x = 0; x < 16; x++) {
|
||||||
|
for (int z = 0; z < 16; z++) {
|
||||||
|
chunk.getBlock(x, 64, z).setType(Material.GRASS_BLOCK, false);
|
||||||
|
for (int y = 65; y <= 68; y++) {
|
||||||
|
chunk.getBlock(x, y, z).setType(Material.AIR, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RuntimeSpawn spawn = runtimeSpawn(chunk, "seeded");
|
||||||
|
IrisSpawner definition = spawn.environment().data().getSpawnerLoader().load("qa/seeded");
|
||||||
|
require(!definition.canSpawn(spawn.engine(), chunkX, 0), "Ongoing weather/time gate unexpectedly allowed spawning");
|
||||||
|
for (int i = 0; i < 100 + chunkX; i++) {
|
||||||
|
RNG.r.nextLong();
|
||||||
|
}
|
||||||
|
ambientInvocation.set(true);
|
||||||
|
try {
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
LivingEntity witness = (LivingEntity) world.spawnEntity(new Location(world, (chunkX << 4) + 8.5, 65, 8.5), EntityType.ZOMBIE);
|
||||||
|
witness.setCustomName("Population witness");
|
||||||
|
witness.setAI(false);
|
||||||
|
witness.setGravity(false);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ambientInvocation.remove();
|
||||||
|
}
|
||||||
|
if (marker) {
|
||||||
|
invokeInitialMarker(spawn, definition, new IrisPosition((chunkX << 4) + 5, 64, 5));
|
||||||
|
} else {
|
||||||
|
invokeAmbient(spawn, chunk, true);
|
||||||
|
}
|
||||||
|
List<String> snapshot = new ArrayList<>();
|
||||||
|
for (Entity entity : chunk.getEntities()) {
|
||||||
|
String name = entity.getCustomName();
|
||||||
|
if (!(entity instanceof LivingEntity living) || name == null || !name.startsWith("Seeded ")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ItemStack hand = living.getEquipment().getItemInMainHand();
|
||||||
|
ItemStack helmet = living.getEquipment().getHelmet();
|
||||||
|
require(hand.hasItemMeta() && hand.getItemMeta().hasAttributeModifiers(), "Spawned gear has no authored attributes");
|
||||||
|
require(helmet != null && helmet.getType() == Material.IRON_HELMET, "Spawned helmet was not authored equipment");
|
||||||
|
Location at = entity.getLocation();
|
||||||
|
snapshot.add(entity.getType() + ":" + at.getX() + ":" + at.getY() + ":" + at.getZ()
|
||||||
|
+ ":" + HexFormat.of().formatHex(hand.serializeAsBytes())
|
||||||
|
+ ":" + HexFormat.of().formatHex(helmet.serializeAsBytes()));
|
||||||
|
}
|
||||||
|
require(snapshot.size() >= 2 && snapshot.size() <= 4, "Initial population was blocked by runtime gates or cap: " + snapshot.size());
|
||||||
|
Collections.sort(snapshot);
|
||||||
|
result.complete(List.copyOf(snapshot));
|
||||||
|
} catch (Throwable failure) {
|
||||||
|
result.completeExceptionally(failure);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void invokeInitialMarker(RuntimeSpawn spawn, IrisSpawner definition, IrisPosition position) {
|
||||||
|
ambientInvocation.set(true);
|
||||||
|
try (BiomeEnvironment.Scope ignored = spawn.engine().openBiomeEnvironmentScope(spawn.environment())) {
|
||||||
|
Method method = spawn.spawner().getClass().getDeclaredMethod("spawnMarker", IrisPosition.class, IrisSpawner.class, IrisMarker.class, RNG.class, boolean.class);
|
||||||
|
method.setAccessible(true);
|
||||||
|
method.invoke(spawn.spawner(), position, definition, null, EntitySpawnSeed.marker(spawn.engine().getSeedManager().getEntity(), position.getX(), position.getY(), position.getZ()), true);
|
||||||
|
} catch (InvocationTargetException failure) {
|
||||||
|
throw new IllegalStateException("Initial marker spawning failed", failure.getCause());
|
||||||
|
} catch (ReflectiveOperationException failure) {
|
||||||
|
throw new IllegalStateException("Could not invoke initial marker spawning", failure);
|
||||||
|
} finally {
|
||||||
|
ambientInvocation.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void spawnFrogs(CommandSender sender) {
|
||||||
|
Chunk chunk = requireWorld().getChunkAt(0, 0);
|
||||||
|
RuntimeSpawn spawn = runtimeSpawn(chunk, "frogs");
|
||||||
|
getLogger().info(spawnDiagnostics(spawn, chunk));
|
||||||
|
for (int attempt = 0; attempt < 12; attempt++) {
|
||||||
|
invokeAmbient(spawn, chunk, false);
|
||||||
|
require(count(chunk, EntityType.FROG) <= FROG_CAPACITY, "Frog batch exceeded its category capacity");
|
||||||
|
}
|
||||||
|
require(count(chunk, EntityType.FROG) == FROG_CAPACITY,
|
||||||
|
"Frog ambient category did not fill: " + spawnDiagnostics(spawn, chunk));
|
||||||
|
require(count(chunk, EntityType.ZOMBIE) == 0, "Unexpected zombie before cave phase");
|
||||||
|
verifyPersistence(chunk);
|
||||||
|
evidence(sender, "SPAWN_QA_FROGS frogs=" + count(chunk, EntityType.FROG)
|
||||||
|
+ " persistent=true removable=true ambient=true");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void spawnZombies(CommandSender sender) {
|
||||||
|
Chunk chunk = requireWorld().getChunkAt(0, 0);
|
||||||
|
require(count(chunk, EntityType.FROG) == FROG_CAPACITY, "Frogs must fill their category before cave spawning");
|
||||||
|
require(count(chunk, EntityType.ZOMBIE) == 0, "Cave phase already ran");
|
||||||
|
RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies");
|
||||||
|
getLogger().info(spawnDiagnostics(spawn, chunk));
|
||||||
|
invokeAmbient(spawn, chunk, false);
|
||||||
|
firstZombieBatch = count(chunk, EntityType.ZOMBIE);
|
||||||
|
require(firstZombieBatch == ZOMBIE_CAPACITY,
|
||||||
|
"Authored seven-zombie batch was not clamped to capacity two: " + spawnDiagnostics(spawn, chunk));
|
||||||
|
require(count(chunk, EntityType.FROG) == FROG_CAPACITY, "Cave spawning changed the passive population");
|
||||||
|
verifyPersistence(chunk);
|
||||||
|
evidence(sender, "SPAWN_QA_ZOMBIES frogs=" + count(chunk, EntityType.FROG)
|
||||||
|
+ " zombies=" + firstZombieBatch + " firstBatch=" + firstZombieBatch + " ambient=true");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stress(CommandSender sender) {
|
||||||
|
Chunk chunk = requireWorld().getChunkAt(0, 0);
|
||||||
|
RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies");
|
||||||
|
for (int attempt = 0; attempt < 50; attempt++) {
|
||||||
|
invokeAmbient(spawn, chunk, false);
|
||||||
|
require(count(chunk, EntityType.ZOMBIE) == ZOMBIE_CAPACITY, "Saturated zombie category exceeded capacity");
|
||||||
|
}
|
||||||
|
evidence(sender, "SPAWN_QA_STRESS attempts=50 zombies=" + count(chunk, EntityType.ZOMBIE));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void check(CommandSender sender) {
|
||||||
|
Chunk chunk = requireWorld().getChunkAt(0, 0);
|
||||||
|
require(count(chunk, EntityType.FROG) == FROG_CAPACITY, "Passive population differs");
|
||||||
|
require(count(chunk, EntityType.ZOMBIE) == ZOMBIE_CAPACITY, "Hostile population differs");
|
||||||
|
require(firstZombieBatch == ZOMBIE_CAPACITY, "First hostile batch was not pinned");
|
||||||
|
require(chunk.getBlock(8, -47, 8).getType() == Material.CAVE_AIR, "Cave is not real CAVE_AIR");
|
||||||
|
require(chunk.getBlock(8, -47, 8).getLightLevel() == 0, "Cave is not dark");
|
||||||
|
for (Entity entity : chunk.getEntities()) {
|
||||||
|
if (entity.getType() == EntityType.ZOMBIE) {
|
||||||
|
require(entity.getLocation().getY() < -42, "Hostile spawned outside the cave");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verifyPersistence(chunk);
|
||||||
|
evidence(sender, "SPAWN_QA_RESULT frogs=16 zombies=2 firstBatch=2 persistent=true removable=true caveAir=true light=0");
|
||||||
|
}
|
||||||
|
|
||||||
|
private RuntimeSpawn runtimeSpawn(Chunk chunk, String biomeKey) {
|
||||||
|
try {
|
||||||
|
Engine engine = IrisToolbelt.access(chunk.getWorld()).getEngine();
|
||||||
|
BiomeEnvironment surface = engine.getSurfaceBiomeEnvironment(8, 8);
|
||||||
|
IrisBiome biome = surface.data().getBiomeLoader().load(biomeKey);
|
||||||
|
require(biome != null, "Synthetic biome was not loaded");
|
||||||
|
BiomeEnvironment selected = new BiomeEnvironment(surface.activationId(), biome, surface.region(),
|
||||||
|
surface.dimension(), surface.data());
|
||||||
|
Object manager = engine.getWorldManager();
|
||||||
|
Field field = manager.getClass().getDeclaredField("entitySpawner");
|
||||||
|
field.setAccessible(true);
|
||||||
|
Object spawner = field.get(manager);
|
||||||
|
Method method = spawner.getClass().getDeclaredMethod("spawnAmbient", Chunk.class, boolean.class, BiomeEnvironment.class);
|
||||||
|
method.setAccessible(true);
|
||||||
|
return new RuntimeSpawn(engine, spawner, method, selected);
|
||||||
|
} catch (ReflectiveOperationException failure) {
|
||||||
|
throw new IllegalStateException("Could not access Iris ambient spawning", failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String spawnDiagnostics(RuntimeSpawn spawn, Chunk chunk) {
|
||||||
|
World world = chunk.getWorld();
|
||||||
|
int floor = world.getHighestBlockYAt(8, 8, HeightMap.OCEAN_FLOOR);
|
||||||
|
int top = world.getHighestBlockYAt(8, 8, HeightMap.WORLD_SURFACE);
|
||||||
|
Map<String, Integer> categories = new TreeMap<>();
|
||||||
|
Map<String, Integer> types = new TreeMap<>();
|
||||||
|
for (Entity entity : chunk.getEntities()) {
|
||||||
|
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
|
||||||
|
categories.merge(BukkitEntityType.of(entity.getType()).spawnCategory(), 1, Integer::sum);
|
||||||
|
types.merge(entity.getType().name(), 1, Integer::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StringBuilder diagnostics = new StringBuilder("SPAWN_QA_DIAGNOSTICS frogs=")
|
||||||
|
.append(count(chunk, EntityType.FROG)).append(" zombies=").append(count(chunk, EntityType.ZOMBIE))
|
||||||
|
.append(" categories=").append(categories).append(" types=").append(types)
|
||||||
|
.append(" loadLevel=").append(chunk.getLoadLevel())
|
||||||
|
.append(" spawnMobs=").append(world.getGameRuleValue(GameRules.SPAWN_MOBS))
|
||||||
|
.append(" animals=").append(world.getAllowAnimals()).append(" monsters=").append(world.getAllowMonsters())
|
||||||
|
.append(" floor=").append(floor).append(" top=").append(top)
|
||||||
|
.append(" support=").append(world.getBlockAt(8, top, 8).getType())
|
||||||
|
.append(" above=").append(world.getBlockAt(8, top + 1, 8).getType())
|
||||||
|
.append(" light=").append(world.getBlockAt(8, top + 1, 8).getLightLevel())
|
||||||
|
.append(" events=").append(ambientEvents).append(" cancelled=").append(cancelledAmbientEvents);
|
||||||
|
for (String key : spawn.environment().biome().getEntitySpawners()) {
|
||||||
|
IrisSpawner spawner = spawn.environment().data().getSpawnerLoader().load(key);
|
||||||
|
if (spawner == null) {
|
||||||
|
diagnostics.append(" spawner=").append(key).append(":missing");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
diagnostics.append(" spawner=").append(key).append(" canSpawn=")
|
||||||
|
.append(spawner.canSpawn(spawn.engine(), chunk.getX(), chunk.getZ()))
|
||||||
|
.append(" excluded=").append(spawner.isCompatExcluded())
|
||||||
|
.append(" group=").append(spawner.getGroup()).append(" capacity=").append(spawner.getMaxEntitiesPerChunk());
|
||||||
|
for (IrisEntitySpawn entry : spawner.getSpawns()) {
|
||||||
|
IrisEntity entity = entry.getRealEntity(spawn.engine());
|
||||||
|
diagnostics.append(" entity=").append(entry.getEntity());
|
||||||
|
if (entity == null) {
|
||||||
|
diagnostics.append(":missing");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
diagnostics.append(" type=").append(entity.getBukkitType()).append(" category=").append(entity.spawnCategory())
|
||||||
|
.append(" surface=").append(entity.getSurface())
|
||||||
|
.append(" supportMatches=").append(entity.getSurface().matches(world.getBlockAt(8, top, 8)))
|
||||||
|
.append(" bounds=").append(BukkitPlatform.entityBoundingBox(entity.getBukkitType()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diagnostics.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void invokeAmbient(RuntimeSpawn spawn, Chunk chunk, boolean initial) {
|
||||||
|
ambientInvocation.set(true);
|
||||||
|
try (BiomeEnvironment.Scope ignored = spawn.engine().openBiomeEnvironmentScope(spawn.environment())) {
|
||||||
|
spawn.method().invoke(spawn.spawner(), chunk, initial, spawn.environment());
|
||||||
|
} catch (InvocationTargetException failure) {
|
||||||
|
throw new IllegalStateException("Iris ambient spawning failed", failure.getCause());
|
||||||
|
} catch (ReflectiveOperationException failure) {
|
||||||
|
throw new IllegalStateException("Could not invoke Iris ambient spawning", failure);
|
||||||
|
} finally {
|
||||||
|
ambientInvocation.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int count(Chunk chunk, EntityType type) {
|
||||||
|
int count = 0;
|
||||||
|
for (Entity entity : chunk.getEntities()) {
|
||||||
|
if (entity.getType() == type) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void verifyPersistence(Chunk chunk) {
|
||||||
|
for (Entity entity : chunk.getEntities()) {
|
||||||
|
if (entity.getType() == EntityType.FROG || entity.getType() == EntityType.ZOMBIE) {
|
||||||
|
require(entity.isPersistent(), "Ambient entity stopped participating in chunk saves");
|
||||||
|
require(entity instanceof LivingEntity living && living.getRemoveWhenFarAway(),
|
||||||
|
"Ambient entity has distance despawning disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void view(CommandSender sender, double y) {
|
||||||
|
teleport(sender, requireWorld(), y, "SPAWN_QA_VIEW y=" + (int) y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void teleport(CommandSender sender, World world, double y, String message) {
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
throw new IllegalArgumentException("Viewing requires a player");
|
||||||
|
}
|
||||||
|
player.getScheduler().run(this, task -> {
|
||||||
|
player.setGameMode(GameMode.CREATIVE);
|
||||||
|
player.setAllowFlight(true);
|
||||||
|
player.setFlying(true);
|
||||||
|
player.teleportAsync(new Location(world, 40.5D, y, 8.5D)).whenComplete((success, failure) -> {
|
||||||
|
if (failure != null || !Boolean.TRUE.equals(success)) {
|
||||||
|
fail(sender, failure == null ? new IllegalStateException("Fixture teleport failed") : failure);
|
||||||
|
} else {
|
||||||
|
notifySender(sender, message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private World requireWorld() {
|
||||||
|
World world = Bukkit.getWorld(WORLD_KEY);
|
||||||
|
require(ready && world != null, "Run setup before a spawning phase");
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runRegion(CommandSender sender, Runnable operation) {
|
||||||
|
World world = requireWorld();
|
||||||
|
Bukkit.getRegionScheduler().execute(this, world, 0, 0, () -> {
|
||||||
|
try {
|
||||||
|
operation.run();
|
||||||
|
} catch (Throwable failure) {
|
||||||
|
fail(sender, failure);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void evidence(CommandSender sender, String message) {
|
||||||
|
getLogger().info(message);
|
||||||
|
notifySender(sender, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifySender(CommandSender sender, String message) {
|
||||||
|
if (sender instanceof Player player) {
|
||||||
|
player.getScheduler().run(this, task -> player.sendMessage(message), null);
|
||||||
|
} else {
|
||||||
|
sender.sendMessage(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fail(CommandSender sender, Throwable failure) {
|
||||||
|
getLogger().log(Level.SEVERE, "Spawning acceptance failed", failure);
|
||||||
|
notifySender(sender, "SPAWN_QA_FAILED " + failure.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void require(boolean condition, String message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new IllegalStateException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CompletionRun(Engine engine, Object maintenance, Method run, Field pending, Runnable callback, AtomicInteger callbacks) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record RuntimeSpawn(Engine engine, Object spawner, Method method, BiomeEnvironment environment) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "Cave Zombies",
|
||||||
|
"derivative": "minecraft:plains",
|
||||||
|
"vanillaDerivative": "minecraft:plains",
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"minHeight": 1,
|
||||||
|
"maxHeight": 1,
|
||||||
|
"palette": [
|
||||||
|
{
|
||||||
|
"block": "minecraft:grass_block"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"generators": [
|
||||||
|
{
|
||||||
|
"generator": "flat",
|
||||||
|
"min": 64,
|
||||||
|
"max": 64
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"entitySpawners": [
|
||||||
|
"qa/cave-zombies"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "Frogs",
|
||||||
|
"derivative": "minecraft:plains",
|
||||||
|
"vanillaDerivative": "minecraft:plains",
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"minHeight": 1,
|
||||||
|
"maxHeight": 1,
|
||||||
|
"palette": [
|
||||||
|
{
|
||||||
|
"block": "minecraft:grass_block"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"generators": [
|
||||||
|
{
|
||||||
|
"generator": "flat",
|
||||||
|
"min": 64,
|
||||||
|
"max": 64
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"entitySpawners": [
|
||||||
|
"qa/frogs"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "Seeded population",
|
||||||
|
"derivative": "minecraft:plains",
|
||||||
|
"vanillaDerivative": "minecraft:plains",
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"minHeight": 1,
|
||||||
|
"maxHeight": 1,
|
||||||
|
"palette": [
|
||||||
|
{
|
||||||
|
"block": "minecraft:grass_block"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"generators": [
|
||||||
|
{
|
||||||
|
"generator": "flat",
|
||||||
|
"min": 64,
|
||||||
|
"max": 64
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"entitySpawners": [
|
||||||
|
"qa/seeded"
|
||||||
|
]
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "Spawn QA",
|
||||||
|
"version": 1,
|
||||||
|
"mode": {
|
||||||
|
"type": "OVERWORLD"
|
||||||
|
},
|
||||||
|
"regions": [
|
||||||
|
"flat"
|
||||||
|
],
|
||||||
|
"focusRegion": "flat",
|
||||||
|
"focus": "frogs",
|
||||||
|
"landChance": 1,
|
||||||
|
"environment": "NORMAL",
|
||||||
|
"dimensionHeight": {
|
||||||
|
"min": -64,
|
||||||
|
"max": 320
|
||||||
|
},
|
||||||
|
"logicalHeight": 384,
|
||||||
|
"fluidHeight": 0,
|
||||||
|
"bedrock": false,
|
||||||
|
"rockPalette": {
|
||||||
|
"palette": [
|
||||||
|
{
|
||||||
|
"block": "minecraft:stone"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"type": "minecraft:frog",
|
||||||
|
"reason": "NATURAL",
|
||||||
|
"surface": "LAND",
|
||||||
|
"ai": false,
|
||||||
|
"aware": false,
|
||||||
|
"gravity": false
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"type": "minecraft:husk",
|
||||||
|
"reason": "NATURAL",
|
||||||
|
"surface": "LAND",
|
||||||
|
"ai": false,
|
||||||
|
"aware": false,
|
||||||
|
"gravity": false,
|
||||||
|
"customName": "Seeded husk",
|
||||||
|
"invulnerable": true,
|
||||||
|
"helmet": {
|
||||||
|
"type": "IRON_HELMET",
|
||||||
|
"minDurability": 0.1,
|
||||||
|
"maxDurability": 0.9
|
||||||
|
},
|
||||||
|
"mainHand": {
|
||||||
|
"type": "IRON_SWORD",
|
||||||
|
"minDurability": 0.1,
|
||||||
|
"maxDurability": 0.9,
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"name": "seeded_damage",
|
||||||
|
"attribute": "minecraft:attack_damage",
|
||||||
|
"minAmount": 1.25,
|
||||||
|
"maxAmount": 6.75,
|
||||||
|
"chance": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"type": "minecraft:zombie",
|
||||||
|
"reason": "NATURAL",
|
||||||
|
"surface": "LAND",
|
||||||
|
"ai": false,
|
||||||
|
"aware": false,
|
||||||
|
"gravity": false,
|
||||||
|
"customName": "Seeded zombie",
|
||||||
|
"invulnerable": true,
|
||||||
|
"helmet": {
|
||||||
|
"type": "IRON_HELMET",
|
||||||
|
"minDurability": 0.1,
|
||||||
|
"maxDurability": 0.9
|
||||||
|
},
|
||||||
|
"mainHand": {
|
||||||
|
"type": "IRON_SWORD",
|
||||||
|
"minDurability": 0.1,
|
||||||
|
"maxDurability": 0.9,
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"name": "seeded_damage",
|
||||||
|
"attribute": "minecraft:attack_damage",
|
||||||
|
"minAmount": 1.25,
|
||||||
|
"maxAmount": 6.75,
|
||||||
|
"chance": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"type": "minecraft:zombie",
|
||||||
|
"reason": "NATURAL",
|
||||||
|
"surface": "LAND",
|
||||||
|
"ai": false,
|
||||||
|
"aware": false,
|
||||||
|
"gravity": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"interpolator": {
|
||||||
|
"function": "NONE",
|
||||||
|
"horizontalScale": 1
|
||||||
|
},
|
||||||
|
"seed": 310,
|
||||||
|
"composite": [
|
||||||
|
{
|
||||||
|
"seed": 310,
|
||||||
|
"style": {
|
||||||
|
"style": "FLAT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "Flat",
|
||||||
|
"landBiomes": [
|
||||||
|
"frogs"
|
||||||
|
],
|
||||||
|
"seaBiomes": [
|
||||||
|
"frogs"
|
||||||
|
],
|
||||||
|
"shoreBiomes": [
|
||||||
|
"frogs"
|
||||||
|
]
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"group": "CAVE",
|
||||||
|
"maxEntitiesPerChunk": 2,
|
||||||
|
"allowedLightLevels": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 0
|
||||||
|
},
|
||||||
|
"spawns": [
|
||||||
|
{
|
||||||
|
"entity": "qa/zombie",
|
||||||
|
"rarity": 1,
|
||||||
|
"minSpawns": 7,
|
||||||
|
"maxSpawns": 7
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"group": "NORMAL",
|
||||||
|
"maxEntitiesPerChunk": 16,
|
||||||
|
"spawns": [
|
||||||
|
{
|
||||||
|
"entity": "qa/frog",
|
||||||
|
"rarity": 1,
|
||||||
|
"minSpawns": 7,
|
||||||
|
"maxSpawns": 7
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"group": "NORMAL",
|
||||||
|
"maxEntitiesPerChunk": 4,
|
||||||
|
"allowedLightLevels": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 0
|
||||||
|
},
|
||||||
|
"weather": "DOWNFALL",
|
||||||
|
"timeBlock": {
|
||||||
|
"startHour": 20,
|
||||||
|
"endHour": 3
|
||||||
|
},
|
||||||
|
"maximumRate": {
|
||||||
|
"amount": 1,
|
||||||
|
"per": {
|
||||||
|
"days": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"initialSpawns": [
|
||||||
|
{
|
||||||
|
"entity": "qa/seeded-zombie",
|
||||||
|
"rarity": 1,
|
||||||
|
"minSpawns": 2,
|
||||||
|
"maxSpawns": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity": "qa/seeded-husk",
|
||||||
|
"rarity": 2,
|
||||||
|
"minSpawns": 2,
|
||||||
|
"maxSpawns": 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export default {
|
||||||
|
name: 'iris-natural-spawning',
|
||||||
|
description: 'Observe category-limited Iris ambient frogs and cave zombies on Folia.',
|
||||||
|
async run(context) {
|
||||||
|
const wait = (predicate, label, timeoutMs = 30000) => context.waitUntil(predicate, { label, timeoutMs, intervalMs: 100 })
|
||||||
|
const command = async (operation, expected, timeoutMs = 30000) => {
|
||||||
|
const response = await context.command(`/irisspawnqa ${operation}`, /SPAWN_QA_[A-Z]+(?:\s|$)/, timeoutMs)
|
||||||
|
context.expect(response.includes(expected) && !response.includes('SPAWN_QA_FAILED'), response)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
const entities = name => Object.values(context.bot.entities).filter(entity => entity.name === name
|
||||||
|
&& entity.position.x >= 0 && entity.position.x < 16 && entity.position.z >= 0 && entity.position.z < 16)
|
||||||
|
await context.step('prepare the synthetic Iris world and dark cave', async () => {
|
||||||
|
await command('setup', 'SPAWN_QA_SETUP world=iris:spawn_qa seed=78264193', 180000)
|
||||||
|
await wait(() => Math.abs(context.bot.entity.position.y - 67) < 1, 'Fixture world teleport')
|
||||||
|
})
|
||||||
|
await context.step('fill the passive category through real Iris ambient spawning', async () => {
|
||||||
|
await command('frogs', 'SPAWN_QA_FROGS frogs=16 persistent=true removable=true ambient=true', 60000)
|
||||||
|
await wait(() => entities('frog').length === 16, 'All sixteen ambient frogs became visible')
|
||||||
|
context.report.irisNaturalSpawning = { visibleFrogs: entities('frog').length }
|
||||||
|
})
|
||||||
|
await context.step('spawn an authored seven-zombie batch with capacity two despite existing frogs', async () => {
|
||||||
|
await command('zombies', 'SPAWN_QA_ZOMBIES frogs=16 zombies=2 firstBatch=2 ambient=true', 60000)
|
||||||
|
await command('cave', 'SPAWN_QA_VIEW y=-46')
|
||||||
|
await wait(() => Math.abs(context.bot.entity.position.y + 46) < 1, 'Underground fixture teleport')
|
||||||
|
await wait(() => context.bot.blockAt(context.bot.entity.position.clone().set(8, -47, 8))?.name === 'cave_air', 'Live cave_air block reached the client')
|
||||||
|
await wait(() => entities('zombie').length === 2, 'Both ambient cave zombies became visible')
|
||||||
|
context.expect(entities('zombie').every(entity => entity.position.y < -42), 'Zombies were not inside the underground chamber')
|
||||||
|
context.report.irisNaturalSpawning.visibleZombies = entities('zombie').length
|
||||||
|
context.report.irisNaturalSpawning.caveAir = true
|
||||||
|
})
|
||||||
|
await context.step('repeat fifty ambient attempts without exceeding the hostile capacity', async () => {
|
||||||
|
await command('stress', 'SPAWN_QA_STRESS attempts=50 zombies=2', 60000)
|
||||||
|
await command('check', 'SPAWN_QA_RESULT frogs=16 zombies=2 firstBatch=2 persistent=true removable=true caveAir=true light=0', 30000)
|
||||||
|
context.expect(entities('zombie').length === 2, 'Client observed a hostile capacity overflow')
|
||||||
|
context.report.irisNaturalSpawning.saturatedAttempts = 50
|
||||||
|
context.report.irisNaturalSpawning.firstHostileBatch = 2
|
||||||
|
context.report.irisNaturalSpawning.removeWhenFarAwayAllowed = true
|
||||||
|
context.report.irisNaturalSpawning.chunkSaving = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "Flat",
|
||||||
|
"derivative": "minecraft:plains",
|
||||||
|
"vanillaDerivative": "minecraft:plains",
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"minHeight": 1,
|
||||||
|
"maxHeight": 1,
|
||||||
|
"palette": [
|
||||||
|
{
|
||||||
|
"block": "minecraft:lime_wool"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"minHeight": 384,
|
||||||
|
"maxHeight": 384,
|
||||||
|
"palette": [
|
||||||
|
{
|
||||||
|
"block": "minecraft:green_concrete"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"generators": [
|
||||||
|
{
|
||||||
|
"generator": "flat",
|
||||||
|
"min": 64,
|
||||||
|
"max": 96
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "Noise Sources",
|
||||||
|
"version": 1,
|
||||||
|
"mode": {
|
||||||
|
"type": "OVERWORLD"
|
||||||
|
},
|
||||||
|
"regions": [
|
||||||
|
"flat"
|
||||||
|
],
|
||||||
|
"focusRegion": "flat",
|
||||||
|
"focus": "flat",
|
||||||
|
"landChance": 1,
|
||||||
|
"environment": "NORMAL",
|
||||||
|
"dimensionHeight": {
|
||||||
|
"min": -64,
|
||||||
|
"max": 320
|
||||||
|
},
|
||||||
|
"logicalHeight": 384,
|
||||||
|
"fluidHeight": 0,
|
||||||
|
"bedrock": false,
|
||||||
|
"rockPalette": {
|
||||||
|
"palette": [
|
||||||
|
{
|
||||||
|
"block": "minecraft:red_concrete"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"expression": "0.5 + sin(x * 0.15) * 0.25 + cos(y * 0.13) * 0.25"
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"interpolator": {
|
||||||
|
"function": "NONE",
|
||||||
|
"horizontalScale": 1
|
||||||
|
},
|
||||||
|
"seed": 310,
|
||||||
|
"composite": [
|
||||||
|
{
|
||||||
|
"seed": 310,
|
||||||
|
"style": {
|
||||||
|
"expression": "terrain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seed": 311,
|
||||||
|
"style": {
|
||||||
|
"style": "CLOVER_HERMITE",
|
||||||
|
"zoom": 0.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "Flat",
|
||||||
|
"landBiomes": [
|
||||||
|
"flat"
|
||||||
|
],
|
||||||
|
"seaBiomes": [
|
||||||
|
"flat"
|
||||||
|
],
|
||||||
|
"shoreBiomes": [
|
||||||
|
"flat"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'iris-noise',
|
||||||
|
description: 'Verify expression and interpolated noise produce varied, bounded terrain visible to a player.',
|
||||||
|
async run(context) {
|
||||||
|
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
|
||||||
|
context.expect(/^isolated=true\s*$/m.test(source), 'Noise generation requires an isolated instance')
|
||||||
|
await context.step('create a world with expression and Clover noise', async () => {
|
||||||
|
const response = await context.command('/iris create name=noise_probe type=noise-fixture seed=78264193',
|
||||||
|
/Successfully created your world|Failed to create/, 180000)
|
||||||
|
context.expect(response.includes('Successfully created your world'), response)
|
||||||
|
})
|
||||||
|
await context.step('client observes bounded terrain with varying heights', async () => {
|
||||||
|
let columns = 0
|
||||||
|
let minimum = Infinity
|
||||||
|
let maximum = -Infinity
|
||||||
|
await context.waitUntil(() => {
|
||||||
|
columns = 0
|
||||||
|
minimum = Infinity
|
||||||
|
maximum = -Infinity
|
||||||
|
const center = context.bot.entity.position.floored()
|
||||||
|
for (let x = -8; x <= 8; x++) {
|
||||||
|
for (let z = -8; z <= 8; z++) {
|
||||||
|
let surface = null
|
||||||
|
for (let y = 60; y <= 100; y++) {
|
||||||
|
const position = center.offset(x, y - center.y, z)
|
||||||
|
const block = context.bot.blockAt(position)
|
||||||
|
if (!block) return false
|
||||||
|
if (block.name === 'lime_wool') {
|
||||||
|
if (surface !== null || context.bot.blockAt(position.offset(0, 1, 0))?.name !== 'air') return false
|
||||||
|
surface = y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (surface === null || surface < 64 || surface > 96) return false
|
||||||
|
columns++
|
||||||
|
minimum = Math.min(minimum, surface)
|
||||||
|
maximum = Math.max(maximum, surface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return columns === 289 && maximum - minimum >= 3
|
||||||
|
}, { label: '289 noise columns with bounded, varied surfaces', timeoutMs: 60000, intervalMs: 250 })
|
||||||
|
context.expect(columns === 289 && maximum - minimum >= 3, 'Client verified noise terrain', { columns, minimum, maximum })
|
||||||
|
context.report.noiseTerrain = { columns, minimum, maximum }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const chunks = [[-64, -64], [64, -64], [-64, 64], [64, 64]]
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'iris-seed-cohesion',
|
||||||
|
description: 'Compare client-visible decorated chunks in two same-seed worlds explored in opposite orders.',
|
||||||
|
async run(context) {
|
||||||
|
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
|
||||||
|
context.expect(/^isolated=true\s*$/m.test(source), 'Seed cohesion requires an isolated instance')
|
||||||
|
const wait = (predicate, label) => context.waitUntil(predicate, { label, timeoutMs: 60000, intervalMs: 100 })
|
||||||
|
const currentWorld = async () => {
|
||||||
|
const observation = await context.observe()
|
||||||
|
const player = observation.players.find(entry => entry.username === context.bot.username)
|
||||||
|
return observation.worlds.find(entry => entry.id === player?.world)
|
||||||
|
}
|
||||||
|
const enter = async name => {
|
||||||
|
await context.command(`/iris tp ${name}`)
|
||||||
|
await wait(async () => (await currentWorld())?.key === `iris:${name}`
|
||||||
|
|| (await currentWorld())?.name.endsWith(name), `Enter ${name}`)
|
||||||
|
await context.command('/minecraft:gamemode spectator @s')
|
||||||
|
await wait(() => context.bot.game.gameMode === 'spectator', 'Spectator mode')
|
||||||
|
await context.command('/minecraft:seed', /Seed:.*1337/)
|
||||||
|
}
|
||||||
|
const capture = async coordinates => {
|
||||||
|
const hashes = {}
|
||||||
|
const decorators = {}
|
||||||
|
let decorationCount = 0
|
||||||
|
for (const [cx, cz] of coordinates) {
|
||||||
|
const x = cx * 16
|
||||||
|
const z = cz * 16
|
||||||
|
await context.command(`/minecraft:tp @s ${x + 8.5} 48 ${z + 8.5}`, /Teleported/i, 30000)
|
||||||
|
await wait(() => Math.abs(context.bot.entity.position.x - x - 8.5) < 1
|
||||||
|
&& Math.abs(context.bot.entity.position.z - z - 8.5) < 1, 'Chunk sample teleport')
|
||||||
|
const position = context.bot.entity.position.clone()
|
||||||
|
await wait(() => context.bot.blockAt(position.clone().set(x, 1, z))
|
||||||
|
&& context.bot.blockAt(position.clone().set(x + 15, 63, z + 15)), 'Complete sample chunk')
|
||||||
|
const digest = createHash('sha256')
|
||||||
|
let decoration = 0
|
||||||
|
let terrain = 0
|
||||||
|
for (let dx = 0; dx < 16; dx++) {
|
||||||
|
for (let dz = 0; dz < 16; dz++) {
|
||||||
|
for (let y = 0; y < 64; y++) {
|
||||||
|
const block = context.bot.blockAt(position.set(x + dx, y, z + dz))
|
||||||
|
context.expect(block !== null, 'Missing sampled block', { x: x + dx, y, z: z + dz })
|
||||||
|
digest.update(`${block.stateId},`)
|
||||||
|
if (block.name === 'granite' || block.name === 'andesite') decoration++
|
||||||
|
if (block.name === 'stone') terrain++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.expect(terrain > 1000, 'Fixture terrain was not generated', { cx, cz, terrain })
|
||||||
|
decorationCount += decoration
|
||||||
|
decorators[`${cx},${cz}`] = decoration
|
||||||
|
hashes[`${cx},${cz}`] = digest.digest('hex')
|
||||||
|
}
|
||||||
|
context.expect(decorationCount > 0, 'Fixture decorators were not generated in sampled chunks', { decorators })
|
||||||
|
context.report.decoratorCoverage ??= []
|
||||||
|
context.report.decoratorCoverage.push(decorators)
|
||||||
|
return hashes
|
||||||
|
}
|
||||||
|
const baseline = 'seed-cohesion-forward'
|
||||||
|
const reverse = 'seed-cohesion-reverse'
|
||||||
|
const stateFile = path.join(context.server.directory, '.seed-cohesion.json')
|
||||||
|
const jarHash = createHash('sha256').update(await readFile(path.join(context.server.directory, 'plugins', 'Iris.jar'))).digest('hex')
|
||||||
|
const restart = context.options.command === 'restart-check'
|
||||||
|
const saved = restart ? JSON.parse(await readFile(stateFile, 'utf8')) : undefined
|
||||||
|
if (restart) {
|
||||||
|
context.expect(jarHash === saved.jarHash, 'Restart-check requires the same Iris jar', { before: saved.jarHash, after: jarHash })
|
||||||
|
context.expect((await context.observe()).processId !== saved.processId, 'Restart-check requires a cold server restart')
|
||||||
|
}
|
||||||
|
const outputs = []
|
||||||
|
for (const [name, coordinates] of [[baseline, chunks], [reverse, [...chunks].reverse()]]) {
|
||||||
|
await context.step(`${restart ? 'Reload' : 'Create'} and sample ${name}`, async () => {
|
||||||
|
if (!restart) {
|
||||||
|
await context.command(`/iris create name=${name} type=cohesion seed=1337`, /Successfully created your world/i, 180000)
|
||||||
|
}
|
||||||
|
await enter(name)
|
||||||
|
const output = await capture(coordinates)
|
||||||
|
if (restart) {
|
||||||
|
for (const [coordinate, hash] of Object.entries(output)) {
|
||||||
|
context.expect(saved.hashes[coordinate] === hash, 'Persisted chunk changed after restart', { name, coordinate, before: saved.hashes[coordinate], after: hash })
|
||||||
|
}
|
||||||
|
Object.assign(output, await capture(coordinates.map(([cx, cz]) => [cx * 3, cz * 3])))
|
||||||
|
}
|
||||||
|
outputs.push(output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await context.step('Compare all sampled blocks', async () => {
|
||||||
|
for (const [coordinate, hash] of Object.entries(outputs[0])) {
|
||||||
|
context.expect(outputs[1][coordinate] === hash, 'Same-seed worlds differ',
|
||||||
|
{ coordinate, forward: hash, reverse: outputs[1][coordinate] })
|
||||||
|
}
|
||||||
|
context.report.seedCohesion = { seed: 1337, jarHash, blocksPerChunk: 16384, restart, hashes: outputs[0] }
|
||||||
|
if (!restart) {
|
||||||
|
await writeFile(stateFile, JSON.stringify({ processId: (await context.observe()).processId, jarHash, hashes: outputs[0] }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export default {
|
||||||
|
name: 'iris-seeded-entities',
|
||||||
|
description: 'Replay initial chunk and marker populations in reversed order, comparing positions and complete equipment data.',
|
||||||
|
async run(context) {
|
||||||
|
const command = async (operation, expected, timeoutMs) => {
|
||||||
|
const response = await context.command(`/irisspawnqa ${operation}`, /SPAWN_QA_[A-Z]+(?:\s|$)/, timeoutMs)
|
||||||
|
context.expect(response.includes(expected) && !response.includes('SPAWN_QA_FAILED'), response)
|
||||||
|
}
|
||||||
|
await context.step('prepare a controlled Iris world', async () => {
|
||||||
|
await command('setup', 'SPAWN_QA_SETUP', 180000)
|
||||||
|
})
|
||||||
|
await context.step('replay initial mobs despite unrelated randomness and live spawn restrictions', async () => {
|
||||||
|
await command('seeded', 'SPAWN_QA_SEEDED chunks=2 markers=2 repeats=2 equipment=true attributes=true', 60000)
|
||||||
|
await context.waitUntil(() => Object.values(context.bot.entities).some(entity => entity.name === 'zombie' || entity.name === 'husk'), {
|
||||||
|
label: 'Generated mobs reached the client', timeoutMs: 10000, intervalMs: 100
|
||||||
|
})
|
||||||
|
context.report.seededEntities = { chunks: 2, markers: 2, repeats: 2, positions: true, equipmentBytes: true, attributeIds: true }
|
||||||
|
})
|
||||||
|
await context.step('retry a skipped initial pass and persist completion once', async () => {
|
||||||
|
await command('completion', 'SPAWN_QA_COMPLETION retry=true callbacks=1 persisted=true owned=true', 30000)
|
||||||
|
context.report.seededEntities.completionRetry = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "Flat",
|
||||||
|
"derivative": "minecraft:plains",
|
||||||
|
"vanillaDerivative": "minecraft:plains",
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"minHeight": 1,
|
||||||
|
"maxHeight": 1,
|
||||||
|
"palette": [
|
||||||
|
{
|
||||||
|
"block": "minecraft:lime_wool"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"minHeight": 384,
|
||||||
|
"maxHeight": 384,
|
||||||
|
"palette": [
|
||||||
|
{
|
||||||
|
"block": "minecraft:stone"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"generators": [
|
||||||
|
{
|
||||||
|
"generator": "flat",
|
||||||
|
"min": 64,
|
||||||
|
"max": 64
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"objects": [
|
||||||
|
{
|
||||||
|
"place": [
|
||||||
|
"object"
|
||||||
|
],
|
||||||
|
"chance": 1,
|
||||||
|
"density": 4,
|
||||||
|
"mode": "CENTER_HEIGHT",
|
||||||
|
"rotation": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"name": "Structure Fixture",
|
||||||
|
"version": 1,
|
||||||
|
"mode": {
|
||||||
|
"type": "OVERWORLD"
|
||||||
|
},
|
||||||
|
"regions": [
|
||||||
|
"flat"
|
||||||
|
],
|
||||||
|
"focusRegion": "flat",
|
||||||
|
"focus": "flat",
|
||||||
|
"landChance": 1,
|
||||||
|
"environment": "NORMAL",
|
||||||
|
"dimensionHeight": {
|
||||||
|
"min": -64,
|
||||||
|
"max": 320
|
||||||
|
},
|
||||||
|
"logicalHeight": 384,
|
||||||
|
"fluidHeight": 0,
|
||||||
|
"bedrock": false,
|
||||||
|
"rockPalette": {
|
||||||
|
"palette": [
|
||||||
|
{
|
||||||
|
"block": "minecraft:stone"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"structures": [
|
||||||
|
{
|
||||||
|
"structures": [
|
||||||
|
"surface"
|
||||||
|
],
|
||||||
|
"placementId": "surface",
|
||||||
|
"distribution": "DENSITY",
|
||||||
|
"density": 1,
|
||||||
|
"anchor": "SURFACE",
|
||||||
|
"terrain": {
|
||||||
|
"mode": "PRESERVE"
|
||||||
|
},
|
||||||
|
"stilt": {
|
||||||
|
"maxDepth": 16,
|
||||||
|
"palette": {
|
||||||
|
"palette": [
|
||||||
|
{
|
||||||
|
"block": "minecraft:diamond_block"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"structures": [
|
||||||
|
"buried"
|
||||||
|
],
|
||||||
|
"placementId": "buried",
|
||||||
|
"distribution": "DENSITY",
|
||||||
|
"density": 1,
|
||||||
|
"anchor": "HEIGHT_BAND",
|
||||||
|
"minHeight": 24,
|
||||||
|
"maxHeight": 24,
|
||||||
|
"terrain": {
|
||||||
|
"mode": "BORE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"interpolator": {
|
||||||
|
"function": "NONE",
|
||||||
|
"horizontalScale": 1
|
||||||
|
},
|
||||||
|
"seed": 310,
|
||||||
|
"composite": [
|
||||||
|
{
|
||||||
|
"seed": 310,
|
||||||
|
"style": {
|
||||||
|
"style": "FLAT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"object": "buried",
|
||||||
|
"connectors": [],
|
||||||
|
"rotatable": false,
|
||||||
|
"rules": {
|
||||||
|
"terminal": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"object": "surface",
|
||||||
|
"connectors": [],
|
||||||
|
"rotatable": false,
|
||||||
|
"rules": {
|
||||||
|
"terminal": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"pieces": [
|
||||||
|
{
|
||||||
|
"piece": "buried",
|
||||||
|
"weight": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"pieces": [
|
||||||
|
{
|
||||||
|
"piece": "surface",
|
||||||
|
"weight": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "Flat",
|
||||||
|
"landBiomes": [
|
||||||
|
"flat"
|
||||||
|
],
|
||||||
|
"seaBiomes": [
|
||||||
|
"flat"
|
||||||
|
],
|
||||||
|
"shoreBiomes": [
|
||||||
|
"flat"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"startPool": "buried",
|
||||||
|
"maxDepth": 1,
|
||||||
|
"maxSizeChunks": 1,
|
||||||
|
"placeMode": "STRUCTURE_PIECE"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"startPool": "surface",
|
||||||
|
"maxDepth": 1,
|
||||||
|
"maxSizeChunks": 1,
|
||||||
|
"placeMode": "STRUCTURE_PIECE"
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'iris-structures',
|
||||||
|
description: 'Verify surface foundations, buried rooms, and waterlogged object blocks in generated chunks.',
|
||||||
|
async run(context) {
|
||||||
|
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
|
||||||
|
context.expect(/^isolated=true\s*$/m.test(source), 'Structure generation requires an isolated instance')
|
||||||
|
const world = context.options.command ?? 'structure_probe'
|
||||||
|
context.expect(/^[a-z0-9_]+$/.test(world), 'Fixture world name must be a simple identifier')
|
||||||
|
await context.step('create the structure fixture world', async () => {
|
||||||
|
const response = await context.command(`/iris create name=${world} type=structure-fixture seed=78264193`,
|
||||||
|
/World ready|Failed to create/, 180000)
|
||||||
|
context.expect(response.includes('World ready'), response)
|
||||||
|
})
|
||||||
|
await context.step('observe structures and objects above and below terrain', async () => {
|
||||||
|
await context.waitUntil(() => {
|
||||||
|
if (Math.abs(context.bot.entity.position.x) > 32 || Math.abs(context.bot.entity.position.z) > 32) return false
|
||||||
|
for (let x = -16; x < 32; x += 16) {
|
||||||
|
for (let z = -16; z < 32; z += 16) {
|
||||||
|
for (let y = 16; y <= 80; y += 16) {
|
||||||
|
if (!context.bot.blockAt(context.bot.entity.position.clone().set(x, y, z))) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}, { label: 'Complete structure fixture chunks', timeoutMs: 60000, intervalMs: 100 })
|
||||||
|
const digest = createHash('sha256')
|
||||||
|
const counts = { emerald_block: 0, diamond_block: 0, blue_concrete: 0, gold_block: 0, oak_slab: 0 }
|
||||||
|
for (let x = -16; x < 32; x++) {
|
||||||
|
for (let z = -16; z < 32; z++) {
|
||||||
|
for (let y = 16; y <= 80; y++) {
|
||||||
|
const block = context.bot.blockAt(context.bot.entity.position.clone().set(x, y, z))
|
||||||
|
context.expect(block !== null, 'Every sampled structure block must be loaded')
|
||||||
|
digest.update(`${block.stateId}\n`)
|
||||||
|
if (Object.hasOwn(counts, block.name)) counts[block.name]++
|
||||||
|
if (block.name === 'emerald_block') context.expect(y > 64, 'Surface platform must be above terrain')
|
||||||
|
if (block.name === 'blue_concrete') context.expect(y < 40, 'Buried room must remain underground')
|
||||||
|
if (block.name === 'oak_slab') context.expect(block.getProperties().waterlogged === false,
|
||||||
|
'Object slabs placed on dry ground must clear waterlogging')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [block, count] of Object.entries(counts)) {
|
||||||
|
context.expect(count > 0, `Generated fixture must contain ${block}`, { count })
|
||||||
|
}
|
||||||
|
context.report.structures = { counts, hash: digest.digest('hex') }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { readFile, stat } from 'node:fs/promises'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'iris-studio-open',
|
||||||
|
description: 'Measure repeated Studio entry and verify visible terrain, spectator mode, and world cleanup.',
|
||||||
|
async run(context) {
|
||||||
|
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
|
||||||
|
context.expect(/^isolated=true\s*$/m.test(source), 'Studio entry requires an isolated instance')
|
||||||
|
const match = /^([a-z0-9_-]+):([1-4])$/.exec(context.options.command ?? 'overworld:2')
|
||||||
|
context.expect(match, 'Expected pack:cycles with one to four cycles')
|
||||||
|
context.report.studioOpen = []
|
||||||
|
for (let cycle = 0; cycle < Number(match[2]); cycle++) {
|
||||||
|
await context.step(`open and close Studio ${cycle + 1}`, async () => {
|
||||||
|
const before = new Set((await context.observe()).worlds.map(world => world.id))
|
||||||
|
const start = Date.now()
|
||||||
|
let world
|
||||||
|
let center
|
||||||
|
await context.command(`/iris studio open ${match[1]} seed=1337`)
|
||||||
|
await context.waitUntil(async () => {
|
||||||
|
const observation = await context.observe()
|
||||||
|
const player = observation.players.find(entry => entry.username === context.bot.username)
|
||||||
|
world = observation.worlds.find(entry => entry.id === player?.world)
|
||||||
|
if (!world || before.has(world.id) || !/iris-[0-9a-f-]{36}$/.test(world.name)) return false
|
||||||
|
center = context.bot.entity.position.floored()
|
||||||
|
return world.loadedChunks > 0 && context.bot.blockAt(center) !== null && context.bot.game.gameMode === 'spectator'
|
||||||
|
}, { label: 'Studio arrival with terrain and spectator mode', timeoutMs: 180000, intervalMs: 100 })
|
||||||
|
const arrivalMs = Date.now() - start
|
||||||
|
context.expect(Number.isInteger(context.bot.game.minY) && Number.isInteger(context.bot.game.height),
|
||||||
|
'Studio dimension must report its vertical range')
|
||||||
|
await context.waitUntil(() => {
|
||||||
|
for (let x = -1; x <= 1; x++) {
|
||||||
|
for (let z = -1; z <= 1; z++) {
|
||||||
|
for (let y = context.bot.game.minY; y < context.bot.game.minY + context.bot.game.height; y += 16) {
|
||||||
|
if (context.bot.blockAt(center.offset(x, y - center.y, z)) === null) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}, { label: 'Complete neighboring Studio columns', timeoutMs: 60000, intervalMs: 100 })
|
||||||
|
const digest = createHash('sha256')
|
||||||
|
let solid = 0
|
||||||
|
for (let x = -1; x <= 1; x++) {
|
||||||
|
for (let z = -1; z <= 1; z++) {
|
||||||
|
for (let y = context.bot.game.minY; y < context.bot.game.minY + context.bot.game.height; y++) {
|
||||||
|
const block = context.bot.blockAt(center.offset(x, y - center.y, z))
|
||||||
|
context.expect(block !== null, 'Studio entry column must be available')
|
||||||
|
digest.update(`${block.name}\n`)
|
||||||
|
if (!['air', 'cave_air', 'void_air', 'water', 'lava'].includes(block.name)) solid++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.expect(solid > 0, 'Studio entry must contain solid terrain')
|
||||||
|
context.report.studioOpen.push({ cycle, arrivalMs, world: world.name, x: center.x, z: center.z,
|
||||||
|
terrainHash: digest.digest('hex'), solid })
|
||||||
|
const closed = await context.command('/iris studio close', /^Studio closed\.$|Studio close failed:/, 180000)
|
||||||
|
context.expect(closed === 'Studio closed.', closed)
|
||||||
|
await context.waitUntil(async () => !(await context.observe()).worlds.some(entry => entry.id === world.id),
|
||||||
|
{ label: 'Studio world unload', timeoutMs: 90000, intervalMs: 100 })
|
||||||
|
const transientName = /iris-[0-9a-f-]{36}$/.exec(world.name)[0]
|
||||||
|
const properties = await readFile(path.join(context.server.directory, 'server.properties'), 'utf8')
|
||||||
|
const level = /^level-name=(.+)$/m.exec(properties)?.[1].trim() ?? 'world'
|
||||||
|
const directory = path.join(context.server.directory, level, 'dimensions', 'iris', transientName)
|
||||||
|
await context.waitUntil(async () => {
|
||||||
|
try { await stat(directory); return false } catch (error) {
|
||||||
|
if (error.code === 'ENOENT') return true
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}, { label: 'Studio directory cleanup', timeoutMs: 90000, intervalMs: 100 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const worldName = 'lifecycle_probe'
|
||||||
|
const worldKey = `iris:${worldName}`
|
||||||
|
const storageName = `world_iris_${worldName}`
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'iris-world-lifecycle',
|
||||||
|
description: 'Create, enter, evacuate, reload, and remove an Iris world through the player commands.',
|
||||||
|
async run(context) {
|
||||||
|
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
|
||||||
|
context.expect(/^isolated=true\s*$/m.test(source), 'World lifecycle requires an isolated instance')
|
||||||
|
const wait = (predicate, label, timeoutMs = 180000) => context.waitUntil(predicate, {
|
||||||
|
label, timeoutMs, intervalMs: 250
|
||||||
|
})
|
||||||
|
const playerWorld = async () => {
|
||||||
|
const observation = await context.observe()
|
||||||
|
const player = observation.players.find(entry => entry.username === context.bot.username)
|
||||||
|
return observation.worlds.find(entry => entry.id === player?.world)?.name
|
||||||
|
}
|
||||||
|
const status = async () => {
|
||||||
|
const response = await context.command('/irislifecycle lifecycle-status', /IRIS_LIFECYCLE |NATIVE_FAILED/, 10000)
|
||||||
|
context.expect(!response.includes('NATIVE_FAILED'), response)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
const settledStatus = async expected => {
|
||||||
|
const response = await status()
|
||||||
|
return response.includes(expected) && response.includes('idle=true')
|
||||||
|
}
|
||||||
|
const assertEvacuated = async label => {
|
||||||
|
let destination
|
||||||
|
await wait(async () => {
|
||||||
|
destination = await playerWorld()
|
||||||
|
return Boolean(destination && destination !== storageName)
|
||||||
|
}, label, 30000)
|
||||||
|
context.expect(destination && destination !== storageName, label, { destination })
|
||||||
|
}
|
||||||
|
const assertSafeEntry = async () => {
|
||||||
|
await wait(async () => (await playerWorld()) === storageName, 'Player entered the lifecycle world')
|
||||||
|
await wait(() => {
|
||||||
|
const feet = context.bot.entity.position.floored()
|
||||||
|
const floor = context.bot.blockAt(feet.offset(0, -1, 0))
|
||||||
|
const body = context.bot.blockAt(feet)
|
||||||
|
const head = context.bot.blockAt(feet.offset(0, 1, 0))
|
||||||
|
return context.bot.entity.onGround && floor?.name === 'lime_wool'
|
||||||
|
&& body?.boundingBox === 'empty' && head?.boundingBox === 'empty'
|
||||||
|
}, 'Client sees solid terrain below unobstructed player entry', 30000)
|
||||||
|
const response = await context.command('/irislifecycle lifecycle-entry', /IRIS_LIFECYCLE_ENTRY|NATIVE_FAILED/, 10000)
|
||||||
|
context.expect(response.includes(`IRIS_LIFECYCLE_ENTRY world=${worldKey} seed=78264193 floor=lime_wool safe=true`), response)
|
||||||
|
}
|
||||||
|
await context.step('confirm disposable world is absent', async () => {
|
||||||
|
const response = await status()
|
||||||
|
context.expect(response.includes('loaded=false registered=false configured=false disk=false quarantines=0 cleanup=deleted idle=true'), response)
|
||||||
|
await context.command('/minecraft:gamemode survival @s')
|
||||||
|
await wait(() => context.bot.game.gameMode === 'survival', 'Survival mode', 10000)
|
||||||
|
})
|
||||||
|
await context.step('create through Iris and automatically enter safe terrain', async () => {
|
||||||
|
await context.command(`/iris create name=${worldName} type=native-terrain seed=78264193`)
|
||||||
|
await wait(async () => await settledStatus('loaded=true registered=true configured=true disk=true'), 'Created world registration and storage')
|
||||||
|
await assertSafeEntry()
|
||||||
|
})
|
||||||
|
await context.step('unload while the player is inside and verify evacuation', async () => {
|
||||||
|
context.expect((await playerWorld()) === storageName, 'Player must be inside the world before unload')
|
||||||
|
await context.command(`/iris unloadWorld ${worldKey}`)
|
||||||
|
await wait(async () => await settledStatus('loaded=false registered=true configured=true disk=true'), 'World unloaded with storage preserved')
|
||||||
|
await assertEvacuated('Unload evacuated the player to a loaded world')
|
||||||
|
})
|
||||||
|
await context.step('reload the saved world and enter it again', async () => {
|
||||||
|
await context.command(`/iris loadWorld ${worldName}`)
|
||||||
|
await wait(async () => await settledStatus('loaded=true registered=true configured=true disk=true'), 'World reloaded')
|
||||||
|
await context.command(`/iris tp ${worldKey}`)
|
||||||
|
await assertSafeEntry()
|
||||||
|
})
|
||||||
|
await context.step('remove the occupied world and verify disk cleanup', async () => {
|
||||||
|
await context.command(`/iris remove world=${worldName} delete=true`)
|
||||||
|
let finalStatus
|
||||||
|
await wait(async () => {
|
||||||
|
finalStatus = await status()
|
||||||
|
return finalStatus.includes('loaded=false registered=false configured=false disk=false')
|
||||||
|
&& /cleanup=(deleted|queued)\b/.test(finalStatus) && finalStatus.includes('idle=true')
|
||||||
|
}, 'Removal detached registrations and deleted or queued quarantine')
|
||||||
|
await assertEvacuated('Removal evacuated the player to a loaded world')
|
||||||
|
context.report.worldLifecycle = { world: worldKey, cleanup: /cleanup=(deleted|queued)\b/.exec(finalStatus)[1] }
|
||||||
|
})
|
||||||
|
if (process.env.IRIS_QA_PACK_URL) {
|
||||||
|
await context.step('download and overwrite the fixture pack through Iris', async () => {
|
||||||
|
const response = await context.command(
|
||||||
|
`/iris download link=${process.env.IRIS_QA_PACK_URL} overwrite=true`,
|
||||||
|
/Iris pack 'native-terrain' installed|Iris pack download failed\./,
|
||||||
|
120000
|
||||||
|
)
|
||||||
|
context.expect(response.includes("Iris pack 'native-terrain' installed"), response)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package art.arcane.iris;
|
||||||
|
|
||||||
|
import art.arcane.iris.platform.bukkit.nms.INMS;
|
||||||
|
import art.arcane.iris.spi.IrisPlatform;
|
||||||
|
import art.arcane.iris.spi.IrisPlatforms;
|
||||||
|
import art.arcane.iris.world.IrisStartupAdmissionListener;
|
||||||
|
import art.arcane.iris.world.IrisStartupValidation;
|
||||||
|
import art.arcane.iris.world.IrisWorldGeneratorResolver;
|
||||||
|
import art.arcane.iris.world.PendingWorldReplacementManager;
|
||||||
|
import art.arcane.iris.world.task.J;
|
||||||
|
import com.destroystokyo.paper.profile.PlayerProfile;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.configuration.file.YamlConfiguration;
|
||||||
|
import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
|
||||||
|
import org.bukkit.generator.ChunkGenerator;
|
||||||
|
import org.bukkit.generator.WorldInfo;
|
||||||
|
import org.bukkit.plugin.PluginManager;
|
||||||
|
import org.junit.After;
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.rules.TemporaryFolder;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertNotNull;
|
||||||
|
import static org.junit.Assert.assertThrows;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paper disables a plugin whose onEnable throws, and CraftServer then builds every world bukkit.yml points at a
|
||||||
|
* disabled generator plugin with the vanilla generator. A failed enable on a server with Iris worlds therefore has
|
||||||
|
* to leave Iris enabled and locked instead.
|
||||||
|
*/
|
||||||
|
public class FailedEnableLockTest {
|
||||||
|
@Rule
|
||||||
|
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void disableValidation() {
|
||||||
|
IrisStartupValidation.disable();
|
||||||
|
IrisPlatforms.unbind();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void enableFailureWithIrisWorldsLocksTheRuntimeInsteadOfDisablingIris() throws Exception {
|
||||||
|
IrisStartupValidation.begin();
|
||||||
|
IllegalStateException failure = new IllegalStateException("service graph exploded");
|
||||||
|
|
||||||
|
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||||
|
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||||
|
assertTrue(FailedEnableLock.engage(failure, true, false));
|
||||||
|
|
||||||
|
String denial = IrisStartupValidation.denialReason().orElseThrow();
|
||||||
|
assertTrue(denial, denial.contains("service graph exploded"));
|
||||||
|
|
||||||
|
ChunkGenerator generator = new IrisWorldGeneratorResolver(null)
|
||||||
|
.resolveDefaultWorldGenerator("world", "overworld");
|
||||||
|
assertNotNull("a null generator is the vanilla generator", generator);
|
||||||
|
IllegalStateException refusal = assertThrows(IllegalStateException.class,
|
||||||
|
() -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
|
||||||
|
assertTrue(refusal.getMessage(), refusal.getMessage().contains("service graph exploded"));
|
||||||
|
bukkit.verify(Bukkit::shutdown, never());
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncPlayerPreLoginEvent login = new AsyncPlayerPreLoginEvent(
|
||||||
|
"LockedTest", InetAddress.getLoopbackAddress(), UUID.randomUUID(), false, mock(PlayerProfile.class));
|
||||||
|
new IrisStartupAdmissionListener().onAsyncPlayerPreLogin(login);
|
||||||
|
assertEquals(AsyncPlayerPreLoginEvent.Result.KICK_OTHER, login.getLoginResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void enableFailureWithoutIrisWorldsLeavesIrisToBeDisabled() {
|
||||||
|
IrisStartupValidation.begin();
|
||||||
|
|
||||||
|
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||||
|
assertFalse(FailedEnableLock.engage(new IllegalStateException("service graph exploded"), false, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
String denial = IrisStartupValidation.denialReason().orElseThrow();
|
||||||
|
assertFalse(denial, denial.contains("service graph exploded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void noticeSaysIrisStaysLockedAndWhatThatBlocks() {
|
||||||
|
String notice = String.join("\n", FailedEnableLock.notice(
|
||||||
|
new IllegalStateException("outer", new IllegalArgumentException("inner")), false));
|
||||||
|
|
||||||
|
assertTrue(notice, notice.contains("Iris failed to enable"));
|
||||||
|
assertTrue(notice, notice.contains("outer"));
|
||||||
|
assertTrue(notice, notice.contains("inner"));
|
||||||
|
assertTrue(notice, notice.contains("vanilla"));
|
||||||
|
assertTrue(notice, notice.contains("startup stops"));
|
||||||
|
assertTrue(notice, notice.contains("logins"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hotload enables Iris on a running server whose startup worlds already exist, so nothing stops.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void noticeAfterStartupWorldsExistSaysTheServerKeepsRunning() {
|
||||||
|
String notice = String.join("\n", FailedEnableLock.notice(new IllegalStateException("outer"), true));
|
||||||
|
|
||||||
|
assertFalse(notice, notice.contains("startup stops"));
|
||||||
|
assertTrue(notice, notice.contains("keeps running"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IrisPlatforms.bind refuses a second platform, which is a real way for enable to throw. It has to throw inside
|
||||||
|
* the guarded region, and the throw must not reach Paper.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void onEnableKeepsIrisEnabledAndLockedWhenEnableThrowsOnAServerWithIrisWorlds() throws Exception {
|
||||||
|
IrisPlatforms.bind(mock(IrisPlatform.class));
|
||||||
|
Iris plugin = plugin();
|
||||||
|
|
||||||
|
try (EnableFixture fixture = new EnableFixture(true)) {
|
||||||
|
plugin.onEnable();
|
||||||
|
|
||||||
|
String denial = IrisStartupValidation.denialReason().orElseThrow();
|
||||||
|
assertTrue(denial, denial.contains("already bound"));
|
||||||
|
ChunkGenerator generator = plugin.getDefaultWorldGenerator("world", "overworld");
|
||||||
|
assertNotNull("a null generator is the vanilla generator", generator);
|
||||||
|
IllegalStateException refusal = assertThrows(IllegalStateException.class,
|
||||||
|
() -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
|
||||||
|
assertTrue(refusal.getMessage(), refusal.getMessage().contains("already bound"));
|
||||||
|
fixture.bukkit.verify(Bukkit::shutdown, never());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void onEnableStillThrowsToPaperWhenTheServerHasNoIrisWorlds() throws Exception {
|
||||||
|
IrisPlatforms.bind(mock(IrisPlatform.class));
|
||||||
|
Iris plugin = plugin();
|
||||||
|
|
||||||
|
try (EnableFixture fixture = new EnableFixture(false)) {
|
||||||
|
IllegalStateException failure = assertThrows(IllegalStateException.class, plugin::onEnable);
|
||||||
|
assertTrue(failure.getMessage(), failure.getMessage().contains("already bound"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void anUnsupportedServerWithIrisWorldsLocksInsteadOfDisablingIris() throws Exception {
|
||||||
|
Iris plugin = plugin();
|
||||||
|
set(plugin, "pendingWorldReplacements", mock(PendingWorldReplacementManager.class));
|
||||||
|
|
||||||
|
try (EnableFixture fixture = new EnableFixture(true);
|
||||||
|
MockedStatic<INMS> nms = mockStatic(INMS.class);
|
||||||
|
MockedStatic<J> scheduler = mockStatic(J.class)) {
|
||||||
|
nms.when(INMS::isBound).thenReturn(false);
|
||||||
|
nms.when(INMS::bindFailure).thenReturn(new IllegalStateException("no NMS binding for 26.9"));
|
||||||
|
|
||||||
|
plugin.onEnable();
|
||||||
|
|
||||||
|
String denial = IrisStartupValidation.denialReason().orElseThrow();
|
||||||
|
assertTrue(denial, denial.contains("no NMS binding for 26.9"));
|
||||||
|
scheduler.verify(() -> J.s(any(Runnable.class), anyInt()), never());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deferring to the server stop quiesces through the NMS binding and waits for generators a failed enable never
|
||||||
|
* made, so a failed enable tears down at once even while the server stops.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void aFailedEnableNeverDefersItsTeardownToTheServerStop() {
|
||||||
|
assertFalse(FailedEnableLock.defersTeardownToServerStop(true, true));
|
||||||
|
assertFalse(FailedEnableLock.defersTeardownToServerStop(true, false));
|
||||||
|
assertTrue(FailedEnableLock.defersTeardownToServerStop(false, true));
|
||||||
|
assertFalse(FailedEnableLock.defersTeardownToServerStop(false, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void irisWorldsAreManagedStorageOrABukkitBindingForAWorldThatLoads() throws Exception {
|
||||||
|
File levelRoot = temporaryFolder.newFolder("level", "world");
|
||||||
|
File bukkit = new File(temporaryFolder.getRoot(), "bukkit.yml");
|
||||||
|
|
||||||
|
assertFalse(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
|
||||||
|
|
||||||
|
YamlConfiguration yaml = new YamlConfiguration();
|
||||||
|
yaml.set("worlds.removed_iris_world.generator", "Iris:overworld");
|
||||||
|
yaml.save(bukkit);
|
||||||
|
assertFalse("a leftover entry for a world that never loads", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
|
||||||
|
|
||||||
|
yaml.set("worlds.world.generator", "Iris:overworld");
|
||||||
|
yaml.save(bukkit);
|
||||||
|
assertTrue("an Iris overworld lives in a vanilla slot", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
|
||||||
|
|
||||||
|
assertTrue(bukkit.delete());
|
||||||
|
assertTrue(new File(levelRoot, "dimensions/iris/moon").mkdirs());
|
||||||
|
assertTrue(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paper builds plugins through its class loader, so the plugin under test is the real class with its fields
|
||||||
|
* set by hand: the resolver getDefaultWorldGenerator delegates to, and whatever enable touches before failing.
|
||||||
|
*/
|
||||||
|
private static Iris plugin() throws Exception {
|
||||||
|
Iris plugin = mock(Iris.class, CALLS_REAL_METHODS);
|
||||||
|
set(plugin, "generatorResolver", new IrisWorldGeneratorResolver(plugin));
|
||||||
|
return plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void set(Iris plugin, String name, Object value) throws Exception {
|
||||||
|
Field field = Iris.class.getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(plugin, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* irisWorldsPresent reads the real server.properties, which a test has none of, so the answer is set here.
|
||||||
|
*/
|
||||||
|
private static final class EnableFixture implements AutoCloseable {
|
||||||
|
private final MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||||
|
private final MockedStatic<Iris> iris = mockStatic(Iris.class);
|
||||||
|
private final MockedStatic<FailedEnableLock> lock = mockStatic(FailedEnableLock.class, CALLS_REAL_METHODS);
|
||||||
|
|
||||||
|
private EnableFixture(boolean irisWorlds) {
|
||||||
|
PluginManager plugins = mock(PluginManager.class);
|
||||||
|
bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);
|
||||||
|
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
|
||||||
|
lock.when(FailedEnableLock::irisWorldsPresent).thenReturn(irisWorlds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
lock.close();
|
||||||
|
iris.close();
|
||||||
|
bukkit.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -8,10 +8,16 @@ import io.papermc.paper.plugin.lifecycle.event.handler.configuration.LifecycleEv
|
|||||||
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEventType;
|
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEventType;
|
||||||
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
|
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
import static org.junit.Assert.assertSame;
|
import static org.junit.Assert.assertSame;
|
||||||
import static org.junit.Assert.assertThrows;
|
import static org.junit.Assert.assertThrows;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.Mockito.atLeastOnce;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
public class IrisBootstrapFailureContractTest {
|
public class IrisBootstrapFailureContractTest {
|
||||||
@@ -32,6 +38,27 @@ public class IrisBootstrapFailureContractTest {
|
|||||||
assertSame(cause, failure.getCause());
|
assertSame(cause, failure.getCause());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void bootstrapFailureIsReportedWithTheRefusalBanner() {
|
||||||
|
BootstrapContext context = mock(BootstrapContext.class);
|
||||||
|
ComponentLogger logger = mock(ComponentLogger.class);
|
||||||
|
when(context.getLogger()).thenReturn(logger);
|
||||||
|
when(context.getLifecycleManager()).thenReturn(new CapturingLifecycleManager());
|
||||||
|
IllegalStateException failure = new IllegalStateException("Iris world storage is unusable",
|
||||||
|
new IOException("Invalid generation manifest: manifest.json"));
|
||||||
|
|
||||||
|
IrisBootstrap.armStartupFailure(context, failure, lifecycleEventType());
|
||||||
|
|
||||||
|
ArgumentCaptor<String> lines = ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(logger, atLeastOnce()).error(lines.capture());
|
||||||
|
String banner = String.join("\n", lines.getAllValues());
|
||||||
|
assertTrue(banner, banner.startsWith("=".repeat(78)));
|
||||||
|
assertTrue(banner, banner.contains("Cause: Iris world storage is unusable"));
|
||||||
|
assertTrue(banner, banner.contains("Caused by: Invalid generation manifest: manifest.json"));
|
||||||
|
assertTrue(banner, banner.contains("no chunks are written"));
|
||||||
|
assertTrue(banner, banner.contains("--safeMode"));
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private static LifecycleEventType<BootstrapContext, LifecycleEvent, ?> lifecycleEventType() {
|
private static LifecycleEventType<BootstrapContext, LifecycleEvent, ?> lifecycleEventType() {
|
||||||
return (LifecycleEventType<BootstrapContext, LifecycleEvent, ?>) mock(LifecycleEventType.class);
|
return (LifecycleEventType<BootstrapContext, LifecycleEvent, ?>) mock(LifecycleEventType.class);
|
||||||
|
|||||||
+75
@@ -175,6 +175,81 @@ public class IrisBootstrapWorldStorageContractTest {
|
|||||||
assertFalse(MissingWorldStorageLog.hasWarned("world_iris_moon"));
|
assertFalse(MissingWorldStorageLog.hasWarned("world_iris_moon"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A vanilla slot that holds an Iris world is generated from its history like any Iris world, so a broken
|
||||||
|
* history stops startup before the level exists instead of reaching the level-load refusal.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void startupIsRefusedWhenABoundVanillaSlotLostItsGenerationHistory() throws Exception {
|
||||||
|
Path serverRoot = temporaryFolder.newFolder("broken-overworld-server").toPath();
|
||||||
|
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/iris/engine-data"));
|
||||||
|
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/region"));
|
||||||
|
Files.writeString(serverRoot.resolve("world/dimensions/minecraft/overworld/region/r.0.0.mca"), "terrain");
|
||||||
|
writeServerProperties(serverRoot);
|
||||||
|
writeBukkitWorlds(serverRoot, "world");
|
||||||
|
|
||||||
|
IllegalStateException failure = assertThrows(
|
||||||
|
IllegalStateException.class,
|
||||||
|
() -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
|
||||||
|
|
||||||
|
assertTrue(failure.getMessage(),
|
||||||
|
failure.getMessage().contains(serverRoot.toRealPath().resolve("world/dimensions/minecraft/overworld")
|
||||||
|
.toString()));
|
||||||
|
assertTrue(failure.getMessage(), failure.getMessage().contains("generation history"));
|
||||||
|
assertFalse(failure.getMessage(), failure.getMessage().contains("Remove worlds."));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void unusableStorageNamesWhyTheHistoryCannotBeRead() throws Exception {
|
||||||
|
Path serverRoot = temporaryFolder.newFolder("cut-history-server").toPath();
|
||||||
|
Path generation = serverRoot.resolve("world/dimensions/minecraft/overworld/iris/generation");
|
||||||
|
Files.createDirectories(generation);
|
||||||
|
Files.writeString(generation.resolve("manifest.json"), "{\"schema\":1,\"epochs\":[{\"id", StandardCharsets.UTF_8);
|
||||||
|
writeServerProperties(serverRoot);
|
||||||
|
writeBukkitWorlds(serverRoot, "world");
|
||||||
|
|
||||||
|
IllegalStateException failure = assertThrows(
|
||||||
|
IllegalStateException.class,
|
||||||
|
() -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
|
||||||
|
|
||||||
|
assertTrue(failure.getMessage(), failure.getMessage().contains("Invalid generation manifest"));
|
||||||
|
assertFalse(failure.getMessage(), failure.getMessage().contains("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Without its bukkit.yml binding the server builds an Iris world in a vanilla slot with the vanilla generator,
|
||||||
|
* which writes vanilla terrain into it.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void startupIsRefusedWhenAnIrisWorldInAVanillaSlotLostItsBinding() throws Exception {
|
||||||
|
Path serverRoot = temporaryFolder.newFolder("unbound-overworld-server").toPath();
|
||||||
|
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/iris/pack"));
|
||||||
|
writeServerProperties(serverRoot);
|
||||||
|
writeBukkitWorlds(serverRoot);
|
||||||
|
|
||||||
|
IllegalStateException failure = assertThrows(
|
||||||
|
IllegalStateException.class,
|
||||||
|
() -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
|
||||||
|
|
||||||
|
assertTrue(failure.getMessage(), failure.getMessage().contains("worlds.world.generator"));
|
||||||
|
assertTrue(failure.getMessage(),
|
||||||
|
failure.getMessage().contains(serverRoot.toRealPath().resolve("world/dimensions/minecraft/overworld")
|
||||||
|
.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void startupContinuesForVanillaSlotsWithoutIrisContent() throws Exception {
|
||||||
|
Path serverRoot = temporaryFolder.newFolder("vanilla-server").toPath();
|
||||||
|
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/region"));
|
||||||
|
Files.writeString(serverRoot.resolve("world/dimensions/minecraft/overworld/region/r.0.0.mca"), "terrain");
|
||||||
|
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/the_nether/iris"));
|
||||||
|
Files.writeString(serverRoot.resolve("world/dimensions/minecraft/the_nether/iris/.DS_Store"), "finder");
|
||||||
|
writeServerProperties(serverRoot);
|
||||||
|
writeBukkitWorlds(serverRoot);
|
||||||
|
|
||||||
|
IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot));
|
||||||
|
}
|
||||||
|
|
||||||
private static BukkitStartupPaths startupPaths(Path serverRoot) throws Exception {
|
private static BukkitStartupPaths startupPaths(Path serverRoot) throws Exception {
|
||||||
return BukkitStartupPaths.resolve(serverRoot, new String[0]);
|
return BukkitStartupPaths.resolve(serverRoot, new String[0]);
|
||||||
}
|
}
|
||||||
|
|||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
package art.arcane.iris.command;
|
||||||
|
|
||||||
|
import art.arcane.iris.pack.PackFingerprints;
|
||||||
|
|
||||||
|
import art.arcane.iris.Iris;
|
||||||
|
import art.arcane.iris.pack.PackValidationRegistry;
|
||||||
|
import art.arcane.iris.pack.PackValidationCache;
|
||||||
|
import art.arcane.iris.pack.PackValidationResult;
|
||||||
|
import art.arcane.iris.pack.PackValidator;
|
||||||
|
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
|
||||||
|
import org.junit.After;
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.rules.TemporaryFolder;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.List;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
|
import static org.junit.Assert.assertSame;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
|
||||||
|
public class CommandPackValidationPublicationTest {
|
||||||
|
@Rule
|
||||||
|
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void clearValidation() {
|
||||||
|
PackValidationRegistry.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void changedCachedPackCannotBePersistedAsFreshValidation() throws Exception {
|
||||||
|
File packs = temporaryFolder.newFolder("packs");
|
||||||
|
File root = new File(packs, "terrain");
|
||||||
|
Files.createDirectories(root.toPath());
|
||||||
|
File content = new File(root, "dimension.json");
|
||||||
|
Files.writeString(content.toPath(), "original");
|
||||||
|
File cacheFile = new File(temporaryFolder.getRoot(), "validation-cache.json");
|
||||||
|
Iris previousPlugin = Iris.instance;
|
||||||
|
Iris plugin = mock(Iris.class);
|
||||||
|
Iris.instance = plugin;
|
||||||
|
when(plugin.getDataFile("cache", "pack-validation.json")).thenReturn(cacheFile);
|
||||||
|
try (MockedStatic<Iris> iris = mockStatic(Iris.class);
|
||||||
|
MockedStatic<PackValidationCache> cache = mockStatic(PackValidationCache.class, CALLS_REAL_METHODS)) {
|
||||||
|
cache.when(PackValidationCache::contextFingerprint).thenReturn("context");
|
||||||
|
PackValidationResult valid = new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
|
||||||
|
PackValidationRegistry.publish(valid);
|
||||||
|
PackValidationRegistry.publish(root.toPath(), valid,
|
||||||
|
PackFingerprints.computePackTreeFingerprint(root), "context");
|
||||||
|
Files.writeString(content.toPath(), "changed");
|
||||||
|
Method persist = CommandPack.class.getDeclaredMethod("persistValidationCache", File.class);
|
||||||
|
persist.setAccessible(true);
|
||||||
|
|
||||||
|
persist.invoke(new CommandPack(), packs);
|
||||||
|
|
||||||
|
assertFalse(cacheFile.exists());
|
||||||
|
Files.writeString(content.toPath(), "original");
|
||||||
|
|
||||||
|
persist.invoke(new CommandPack(), packs);
|
||||||
|
|
||||||
|
assertTrue(cacheFile.exists());
|
||||||
|
assertTrue(PackValidationCache.load(cacheFile.toPath(),
|
||||||
|
PackFingerprints.computePackContentSnapshot(packs).content(), "context",
|
||||||
|
List.of(root.getName())).orElseThrow().getFirst().isLoadable());
|
||||||
|
} finally {
|
||||||
|
Iris.instance = previousPlugin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void commandPublishesCanonicalRootValidation() throws Exception {
|
||||||
|
File root = temporaryFolder.newFolder("terrain");
|
||||||
|
PackValidationResult valid = new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
|
||||||
|
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
|
||||||
|
validator.when(() -> PackValidator.validate(root)).thenReturn(valid);
|
||||||
|
|
||||||
|
assertSame(valid, new CommandPack().runValidate(mock(VolmitSender.class), root));
|
||||||
|
|
||||||
|
assertSame(valid, PackValidationRegistry.get(root.toPath()));
|
||||||
|
assertSame(valid, PackValidationRegistry.get(root.getName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void mutationDuringValidationCannotRepublishStaleResult() throws Exception {
|
||||||
|
File root = temporaryFolder.newFolder("terrain");
|
||||||
|
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
|
||||||
|
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||||
|
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
|
||||||
|
try (PackValidationRegistry.RootMutation mutation = PackValidationRegistry.beginRootMutation(root.toPath())) {
|
||||||
|
return new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertFalse(new CommandPack().runValidate(mock(VolmitSender.class), root).isLoadable());
|
||||||
|
assertNull(PackValidationRegistry.get(root.toPath()));
|
||||||
|
assertNull(PackValidationRegistry.get(root.getName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void externalEditDuringValidationPublishesBlockedResult() throws Exception {
|
||||||
|
File root = temporaryFolder.newFolder("terrain");
|
||||||
|
File content = new File(root, "dimension.json");
|
||||||
|
Files.writeString(content.toPath(), "original");
|
||||||
|
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
|
||||||
|
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||||
|
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
|
||||||
|
Files.writeString(content.toPath(), "changed");
|
||||||
|
return new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertFalse(new CommandPack().runValidate(mock(VolmitSender.class), root).isLoadable());
|
||||||
|
assertFalse(PackValidationRegistry.get(root.toPath()).isLoadable());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package art.arcane.iris.generation.runtime;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public class GenerationRateWindowTest {
|
||||||
|
@Test
|
||||||
|
public void generationTimingsGoStaleOnceChunksStopGenerating() {
|
||||||
|
GenerationRateWindow window = new GenerationRateWindow();
|
||||||
|
|
||||||
|
window.sampleChunksPerSecond(0, 1_000L);
|
||||||
|
assertEquals(20D, window.sampleChunksPerSecond(20, 2_000L), 0D);
|
||||||
|
assertTrue(window.generationTimingsFresh(2_000L));
|
||||||
|
|
||||||
|
assertEquals(0D, window.sampleChunksPerSecond(20, 7_000L), 0D);
|
||||||
|
assertTrue(window.generationTimingsFresh(7_000L));
|
||||||
|
assertEquals(0D, window.sampleChunksPerSecond(20, 12_001L), 0D);
|
||||||
|
assertFalse(window.generationTimingsFresh(12_001L));
|
||||||
|
|
||||||
|
window.sampleChunksPerSecond(21, 13_001L);
|
||||||
|
assertTrue(window.generationTimingsFresh(13_001L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void anIdleWorldNeverPublishesTimings() {
|
||||||
|
GenerationRateWindow window = new GenerationRateWindow();
|
||||||
|
|
||||||
|
window.sampleChunksPerSecond(0, 1_000L);
|
||||||
|
window.sampleChunksPerSecond(0, 2_000L);
|
||||||
|
|
||||||
|
assertFalse(window.generationTimingsFresh(2_000L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void chunksGeneratedBeforeTheFirstSampleCountAsRecentGeneration() {
|
||||||
|
GenerationRateWindow window = new GenerationRateWindow();
|
||||||
|
|
||||||
|
assertEquals(0D, window.sampleChunksPerSecond(400, 5_000L), 0D);
|
||||||
|
|
||||||
|
assertTrue(window.generationTimingsFresh(5_000L));
|
||||||
|
assertFalse(window.generationTimingsFresh(5_000L + GenerationRateWindow.TIMING_IDLE_MILLIS + 1L));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 863 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user