mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-11 19:14:11 +00:00
Rebase
This commit is contained in:
@@ -133,8 +133,11 @@ import org.bukkit.command.Command;
|
|||||||
import org.bukkit.command.CommandSender;
|
import org.bukkit.command.CommandSender;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.event.Event;
|
import org.bukkit.event.Event;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
import org.bukkit.event.HandlerList;
|
import org.bukkit.event.HandlerList;
|
||||||
import org.bukkit.event.Listener;
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.server.PluginDisableEvent;
|
||||||
|
import org.bukkit.event.server.PluginEnableEvent;
|
||||||
import org.bukkit.generator.BiomeProvider;
|
import org.bukkit.generator.BiomeProvider;
|
||||||
import org.bukkit.generator.ChunkGenerator;
|
import org.bukkit.generator.ChunkGenerator;
|
||||||
import org.bukkit.plugin.IllegalPluginAccessException;
|
import org.bukkit.plugin.IllegalPluginAccessException;
|
||||||
@@ -206,6 +209,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
private volatile PlaceholderRegistration papiRegistration;
|
private volatile PlaceholderRegistration papiRegistration;
|
||||||
private volatile IrisPapiListener papiListener;
|
private volatile IrisPapiListener papiListener;
|
||||||
private volatile IrisPapiState papiState;
|
private volatile IrisPapiState papiState;
|
||||||
|
private volatile boolean papiLifecycleActive;
|
||||||
private KMap<Class<? extends IrisService>, IrisService> services;
|
private KMap<Class<? extends IrisService>, IrisService> services;
|
||||||
// Copy-on-write: mutated on the main thread during enable() and iterated by the JVM
|
// Copy-on-write: mutated on the main thread during enable() and iterated by the JVM
|
||||||
// shutdown-hook thread during teardown; a plain list would CME and abort the teardown.
|
// shutdown-hook thread during teardown; a plain list would CME and abort the teardown.
|
||||||
@@ -867,7 +871,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
*/
|
*/
|
||||||
private CompletableFuture<ManagedWorldLoader.ManagedWorldLoad> loadManagedWorld(String configuredWorldName) {
|
private CompletableFuture<ManagedWorldLoader.ManagedWorldLoad> loadManagedWorld(String configuredWorldName) {
|
||||||
return worldReconciler.loadWorld(ServerProperties.BUKKIT_YML, configuredWorldName)
|
return worldReconciler.loadWorld(ServerProperties.BUKKIT_YML, configuredWorldName)
|
||||||
.thenApply(result -> new ManagedWorldLoader.ManagedWorldLoad(result.succeeded(), result.message()));
|
.thenApply(this::managedWorldLoadResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ManagedWorldLoader.ManagedWorldLoad managedWorldLoadResult(BukkitWorldReconciler.LoadResult result) {
|
||||||
|
if (!result.succeeded() && result.failure() != null) {
|
||||||
|
reportError("Could not explicitly load Iris world " + result.worldKey() + ": " + result.message(), result.failure());
|
||||||
|
}
|
||||||
|
return new ManagedWorldLoader.ManagedWorldLoad(result.succeeded(), result.message());
|
||||||
}
|
}
|
||||||
|
|
||||||
public PendingWorldReplacementManager pendingWorldReplacements() {
|
public PendingWorldReplacementManager pendingWorldReplacements() {
|
||||||
@@ -931,6 +942,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
ServerConfigurator.restartAtStartupBoundary(restartReason);
|
ServerConfigurator.restartAtStartupBoundary(restartReason);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
papiLifecycleActive = true;
|
||||||
|
setupPapi();
|
||||||
reportLockedRuntime();
|
reportLockedRuntime();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -977,6 +990,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void onDisable() {
|
public void onDisable() {
|
||||||
|
papiLifecycleActive = false;
|
||||||
if (updateNotifier != null) {
|
if (updateNotifier != null) {
|
||||||
updateNotifier.close();
|
updateNotifier.close();
|
||||||
updateNotifier = null;
|
updateNotifier = null;
|
||||||
@@ -1020,6 +1034,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CompletionStage<Void> commitReload(ReloadAware.PreUnloadReason reason) {
|
public CompletionStage<Void> commitReload(ReloadAware.PreUnloadReason reason) {
|
||||||
|
papiLifecycleActive = false;
|
||||||
teardownPapi();
|
teardownPapi();
|
||||||
if (IrisToolbelt.isServerStopping()) {
|
if (IrisToolbelt.isServerStopping()) {
|
||||||
quiesceRuntimeForServerShutdown("pre-unload:" + reason);
|
quiesceRuntimeForServerShutdown("pre-unload:" + reason);
|
||||||
@@ -1322,8 +1337,23 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setupPapi() {
|
@EventHandler
|
||||||
if (!PlaceholderRegistration.isPlaceholderApiEnabled()) {
|
public void onPlaceholderApiEnable(PluginEnableEvent event) {
|
||||||
|
if ("PlaceholderAPI".equals(event.getPlugin().getName())) {
|
||||||
|
setupPapi();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onPlaceholderApiDisable(PluginDisableEvent event) {
|
||||||
|
if ("PlaceholderAPI".equals(event.getPlugin().getName())) {
|
||||||
|
teardownPapi();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void setupPapi() {
|
||||||
|
if (!papiLifecycleActive || papiRegistration != null
|
||||||
|
|| !PlaceholderRegistration.isPlaceholderApiEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1350,7 +1380,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
papiRegistration = registration;
|
papiRegistration = registration;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void teardownPapi() {
|
private synchronized void teardownPapi() {
|
||||||
IrisPapiListener listener = papiListener;
|
IrisPapiListener listener = papiListener;
|
||||||
papiListener = null;
|
papiListener = null;
|
||||||
|
|
||||||
|
|||||||
@@ -28,34 +28,26 @@ permissions:
|
|||||||
dependencies:
|
dependencies:
|
||||||
server:
|
server:
|
||||||
PlaceholderAPI:
|
PlaceholderAPI:
|
||||||
load: BEFORE
|
load: OMIT
|
||||||
required: false
|
required: false
|
||||||
join-classpath: true
|
join-classpath: true
|
||||||
CraftEngine:
|
CraftEngine:
|
||||||
load: BEFORE
|
load: OMIT
|
||||||
required: false
|
required: false
|
||||||
join-classpath: true
|
join-classpath: true
|
||||||
Nexo:
|
Nexo:
|
||||||
load: BEFORE
|
load: OMIT
|
||||||
required: false
|
required: false
|
||||||
join-classpath: true
|
join-classpath: true
|
||||||
Oraxen:
|
Oraxen:
|
||||||
load: BEFORE
|
load: OMIT
|
||||||
required: false
|
required: false
|
||||||
join-classpath: true
|
join-classpath: true
|
||||||
ItemsAdder:
|
ItemsAdder:
|
||||||
load: BEFORE
|
load: OMIT
|
||||||
required: false
|
required: false
|
||||||
join-classpath: true
|
join-classpath: true
|
||||||
SCore:
|
SCore:
|
||||||
load: BEFORE
|
|
||||||
required: false
|
|
||||||
join-classpath: true
|
|
||||||
ExecutableItems:
|
|
||||||
load: BEFORE
|
|
||||||
required: false
|
|
||||||
join-classpath: false
|
|
||||||
MythicLib:
|
|
||||||
load: OMIT
|
load: OMIT
|
||||||
required: false
|
required: false
|
||||||
join-classpath: true
|
join-classpath: true
|
||||||
@@ -64,11 +56,7 @@ dependencies:
|
|||||||
required: false
|
required: false
|
||||||
join-classpath: true
|
join-classpath: true
|
||||||
eco:
|
eco:
|
||||||
load: BEFORE
|
load: OMIT
|
||||||
required: false
|
|
||||||
join-classpath: true
|
|
||||||
EcoItems:
|
|
||||||
load: BEFORE
|
|
||||||
required: false
|
required: false
|
||||||
join-classpath: true
|
join-classpath: true
|
||||||
MythicMobs:
|
MythicMobs:
|
||||||
@@ -80,7 +68,7 @@ dependencies:
|
|||||||
required: false
|
required: false
|
||||||
join-classpath: true
|
join-classpath: true
|
||||||
KGenerators:
|
KGenerators:
|
||||||
load: BEFORE
|
load: OMIT
|
||||||
required: false
|
required: false
|
||||||
join-classpath: true
|
join-classpath: true
|
||||||
Multiverse-Core:
|
Multiverse-Core:
|
||||||
@@ -88,6 +76,6 @@ dependencies:
|
|||||||
required: false
|
required: false
|
||||||
join-classpath: true
|
join-classpath: true
|
||||||
WorldEdit:
|
WorldEdit:
|
||||||
load: BEFORE
|
load: OMIT
|
||||||
required: false
|
required: false
|
||||||
join-classpath: true
|
join-classpath: true
|
||||||
|
|||||||
@@ -3,18 +3,6 @@ version: ${version}
|
|||||||
main: ${main}
|
main: ${main}
|
||||||
folia-supported: true
|
folia-supported: true
|
||||||
load: STARTUP
|
load: STARTUP
|
||||||
softdepend:
|
|
||||||
- PlaceholderAPI
|
|
||||||
- CraftEngine
|
|
||||||
- Nexo
|
|
||||||
- Oraxen
|
|
||||||
- ItemsAdder
|
|
||||||
- SCore
|
|
||||||
- ExecutableItems
|
|
||||||
- eco
|
|
||||||
- EcoItems
|
|
||||||
- KGenerators
|
|
||||||
- WorldEdit
|
|
||||||
loadbefore: [ Multiverse-Core ]
|
loadbefore: [ Multiverse-Core ]
|
||||||
authors: [ cyberpwn, NextdoorPsycho, Vatuu ]
|
authors: [ cyberpwn, NextdoorPsycho, Vatuu ]
|
||||||
website: VolmitSoftware.com
|
website: VolmitSoftware.com
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { readFile, writeFile } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { setup, openStudio, closeStudio, sampleTerrain, readJson, writeJson,
|
||||||
|
logPosition, logSince } from './studio-support.mjs'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'iris-studio-hotload-lifecycle',
|
||||||
|
description: 'Generate cold chunks during Studio edits and close after saving an edit.',
|
||||||
|
async run(context) {
|
||||||
|
const fixture = await setup(context)
|
||||||
|
const biomeFile = path.join(fixture.pack, 'biomes', 'flat.json')
|
||||||
|
const original = await readFile(biomeFile, 'utf8')
|
||||||
|
const logOffset = await logPosition(fixture)
|
||||||
|
let active
|
||||||
|
let x = 16384
|
||||||
|
try {
|
||||||
|
active = await openStudio(context, fixture)
|
||||||
|
await sampleTerrain(context, fixture, 0, fixture.state.material)
|
||||||
|
for (let cycle = 0; cycle < 3; cycle++) {
|
||||||
|
await context.step(`Load cold chunks during hotload ${cycle + 1}`, async () => {
|
||||||
|
const manifestFile = fixture.manifestPath(active.key)
|
||||||
|
const before = await readJson(manifestFile)
|
||||||
|
const biome = await readJson(biomeFile)
|
||||||
|
const material = cycle % 2 === 0 ? 'gold_block' : 'diamond_block'
|
||||||
|
biome.name = `Lifecycle terrain ${cycle + 1}`
|
||||||
|
biome.layers[0].palette[0].block = `minecraft:${material}`
|
||||||
|
const startedAt = Date.now()
|
||||||
|
await writeJson(biomeFile, biome)
|
||||||
|
x += 512
|
||||||
|
await context.command(`/minecraft:tp @s ${x + 0.5} 101 0.5`, /Teleported/i, 15000)
|
||||||
|
const after = await fixture.wait(async () => {
|
||||||
|
const manifest = await readJson(manifestFile)
|
||||||
|
return manifest.activeActivationId !== before.activeActivationId ? manifest : false
|
||||||
|
}, 'Hotload did not complete while the player requested fresh chunks', 90000)
|
||||||
|
const activationDurationMs = Date.now() - startedAt
|
||||||
|
await fixture.wait(() => Math.abs(context.bot.entity.position.x - x - 0.5) < 1,
|
||||||
|
'Cold chunk teleport did not arrive after hotload', 30000)
|
||||||
|
context.report.iris.activations.push({ before: before.activeActivationId,
|
||||||
|
after: after.activeActivationId, material, coldChunkRequests: 1, activationDurationMs })
|
||||||
|
x += 2048
|
||||||
|
await sampleTerrain(context, fixture, x, material)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await context.step('Close Studio while a saved edit is activating', async () => {
|
||||||
|
const biome = await readJson(biomeFile)
|
||||||
|
biome.name = 'Lifecycle close terrain'
|
||||||
|
await writeJson(biomeFile, biome)
|
||||||
|
const pending = await fixture.wait(async () => {
|
||||||
|
const manifest = await readJson(fixture.manifestPath(active.key))
|
||||||
|
return manifest.pendingActivationId !== null ? manifest : false
|
||||||
|
}, 'The saved close edit did not enter activation', 90000)
|
||||||
|
context.report.iris.closePendingActivationId = pending.pendingActivationId
|
||||||
|
await closeStudio(context, fixture, active)
|
||||||
|
active = undefined
|
||||||
|
await context.command('/minecraft:seed', /Seed:/i, 10000)
|
||||||
|
})
|
||||||
|
await context.step('Reopen the edited pack and retain usable generation', async () => {
|
||||||
|
active = await openStudio(context, fixture)
|
||||||
|
await sampleTerrain(context, fixture, 0, 'gold_block')
|
||||||
|
await closeStudio(context, fixture, active)
|
||||||
|
active = undefined
|
||||||
|
})
|
||||||
|
const log = await logSince(fixture, logOffset)
|
||||||
|
context.expect(!/Studio generation update failed|Unable to (capture terrain|save the terrain boundary)|ClosedChannelException|Chunk system error|server has stopped responding|could not acquire its engine runtime|Failed to list files/i.test(log),
|
||||||
|
'Studio lifecycle emitted a generation, snapshot, storage, or watchdog failure')
|
||||||
|
} finally {
|
||||||
|
if (active) await closeStudio(context, fixture, active)
|
||||||
|
await writeFile(biomeFile, original)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,33 +20,20 @@ import static org.junit.Assert.assertNotNull;
|
|||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
public class PaperPluginMetadataTest {
|
public class PaperPluginMetadataTest {
|
||||||
private static final List<String> OPTIONAL_PLUGIN_IDS = List.of(
|
private static final List<String> JOINED_PLUGIN_IDS = List.of(
|
||||||
"PlaceholderAPI",
|
"PlaceholderAPI",
|
||||||
"CraftEngine",
|
"CraftEngine",
|
||||||
"Nexo",
|
"Nexo",
|
||||||
"Oraxen",
|
"Oraxen",
|
||||||
"ItemsAdder",
|
"ItemsAdder",
|
||||||
"SCore",
|
"SCore",
|
||||||
"ExecutableItems",
|
|
||||||
"MythicLib",
|
|
||||||
"MMOItems",
|
"MMOItems",
|
||||||
"eco",
|
"eco",
|
||||||
"EcoItems",
|
|
||||||
"MythicMobs",
|
"MythicMobs",
|
||||||
"MythicCrucible",
|
"MythicCrucible",
|
||||||
"KGenerators",
|
"KGenerators",
|
||||||
"Multiverse-Core",
|
|
||||||
"WorldEdit"
|
"WorldEdit"
|
||||||
);
|
);
|
||||||
private static final List<String> JOINED_PLUGIN_IDS = OPTIONAL_PLUGIN_IDS.stream()
|
|
||||||
.filter(pluginId -> !"ExecutableItems".equals(pluginId) && !"Multiverse-Core".equals(pluginId))
|
|
||||||
.toList();
|
|
||||||
private static final List<String> UNORDERED_PLUGIN_IDS = List.of(
|
|
||||||
"MythicLib", "MMOItems", "MythicMobs", "MythicCrucible");
|
|
||||||
private static final List<String> BUKKIT_SOFT_DEPEND_IDS = OPTIONAL_PLUGIN_IDS.stream()
|
|
||||||
.filter(pluginId -> !"Multiverse-Core".equals(pluginId))
|
|
||||||
.filter(pluginId -> !UNORDERED_PLUGIN_IDS.contains(pluginId))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void paperMetadataDeclaresBootstrapAndFoliaSupport() throws Exception {
|
public void paperMetadataDeclaresBootstrapAndFoliaSupport() throws Exception {
|
||||||
@@ -75,10 +62,13 @@ public class PaperPluginMetadataTest {
|
|||||||
+ " description: Allows survival players to fell Iris-managed trees with an axe.\n"
|
+ " description: Allows survival players to fell Iris-managed trees with an axe.\n"
|
||||||
+ " default: op"));
|
+ " default: op"));
|
||||||
for (String pluginId : JOINED_PLUGIN_IDS) {
|
for (String pluginId : JOINED_PLUGIN_IDS) {
|
||||||
String loadOrder = UNORDERED_PLUGIN_IDS.contains(pluginId) ? "OMIT" : "BEFORE";
|
assertTrue(metadata.contains(optionalDependencyBlock(pluginId, "OMIT", true)));
|
||||||
assertTrue(metadata.contains(optionalDependencyBlock(pluginId, loadOrder, true)));
|
|
||||||
}
|
}
|
||||||
assertTrue(metadata.contains(optionalDependencyBlock("ExecutableItems", "BEFORE", false)));
|
assertFalse(metadata.contains(" ExecutableItems:"));
|
||||||
|
assertFalse(metadata.contains(" EcoItems:"));
|
||||||
|
assertFalse(metadata.contains(" MythicLib:"));
|
||||||
|
assertEquals(1, metadata.lines().filter(line -> line.equals(" load: AFTER")).count());
|
||||||
|
assertEquals(0, metadata.lines().filter(line -> line.equals(" load: BEFORE")).count());
|
||||||
assertTrue(metadata.contains(optionalDependencyBlock("Multiverse-Core", "AFTER", true)));
|
assertTrue(metadata.contains(optionalDependencyBlock("Multiverse-Core", "AFTER", true)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +85,7 @@ public class PaperPluginMetadataTest {
|
|||||||
Map<String, Map<String, Object>> commands = metadata.getCommands();
|
Map<String, Map<String, Object>> commands = metadata.getCommands();
|
||||||
assertTrue(commands.containsKey("iris"));
|
assertTrue(commands.containsKey("iris"));
|
||||||
assertEquals(List.of("ir", "irs"), commands.get("iris").get("aliases"));
|
assertEquals(List.of("ir", "irs"), commands.get("iris").get("aliases"));
|
||||||
assertEquals(BUKKIT_SOFT_DEPEND_IDS, metadata.getSoftDepend());
|
assertTrue(metadata.getSoftDepend().isEmpty());
|
||||||
assertEquals(List.of("Multiverse-Core"), metadata.getLoadBeforePlugins());
|
assertEquals(List.of("Multiverse-Core"), metadata.getLoadBeforePlugins());
|
||||||
Permission all = metadata.getPermissions().stream()
|
Permission all = metadata.getPermissions().stream()
|
||||||
.filter(permission -> "iris.all".equals(permission.getName()))
|
.filter(permission -> "iris.all".equals(permission.getName()))
|
||||||
|
|||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
package art.arcane.iris.integration;
|
||||||
|
|
||||||
|
import art.arcane.iris.Iris;
|
||||||
|
import art.arcane.volmlib.util.bukkit.papi.PlaceholderRegistration;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.event.HandlerList;
|
||||||
|
import org.bukkit.event.server.PluginDisableEvent;
|
||||||
|
import org.bukkit.event.server.PluginEnableEvent;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
import org.bukkit.plugin.PluginManager;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.MockedConstruction;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doCallRealMethod;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.mockConstruction;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
public class IrisPapiLifecycleTest {
|
||||||
|
@Test
|
||||||
|
public void lateEnableRegistersOnceAndReattachesAfterDisable() throws Exception {
|
||||||
|
Iris iris = plugin(true);
|
||||||
|
Plugin placeholders = namedPlugin("PlaceholderAPI");
|
||||||
|
PluginManager plugins = mock(PluginManager.class);
|
||||||
|
|
||||||
|
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||||
|
MockedStatic<HandlerList> handlers = mockStatic(HandlerList.class);
|
||||||
|
MockedStatic<PlaceholderRegistration> availability = mockStatic(PlaceholderRegistration.class);
|
||||||
|
MockedStatic<IrisPapiInstaller> installer = mockStatic(IrisPapiInstaller.class);
|
||||||
|
MockedConstruction<PlaceholderRegistration> registrations = mockConstruction(PlaceholderRegistration.class)) {
|
||||||
|
bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);
|
||||||
|
availability.when(PlaceholderRegistration::isPlaceholderApiEnabled).thenReturn(true);
|
||||||
|
installer.when(() -> IrisPapiInstaller.install(any(), any(), any())).thenReturn(true);
|
||||||
|
|
||||||
|
iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders));
|
||||||
|
iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders));
|
||||||
|
assertEquals(1, registrations.constructed().size());
|
||||||
|
verify(plugins).registerEvents(any(IrisPapiListener.class), eq(iris));
|
||||||
|
|
||||||
|
iris.onPlaceholderApiDisable(new PluginDisableEvent(namedPlugin("Unrelated")));
|
||||||
|
verify(registrations.constructed().getFirst(), never()).unregister();
|
||||||
|
|
||||||
|
iris.onPlaceholderApiDisable(new PluginDisableEvent(placeholders));
|
||||||
|
verify(registrations.constructed().getFirst()).unregister();
|
||||||
|
handlers.verify(() -> HandlerList.unregisterAll(any(IrisPapiListener.class)));
|
||||||
|
|
||||||
|
iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders));
|
||||||
|
assertEquals(2, registrations.constructed().size());
|
||||||
|
verify(plugins, times(2)).registerEvents(any(IrisPapiListener.class), eq(iris));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void enableEventsCannotRegisterBeforeIrisIsReadyOrDuringShutdown() throws Exception {
|
||||||
|
Iris iris = plugin(false);
|
||||||
|
Plugin placeholders = namedPlugin("PlaceholderAPI");
|
||||||
|
|
||||||
|
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||||
|
MockedStatic<IrisPapiInstaller> installer = mockStatic(IrisPapiInstaller.class)) {
|
||||||
|
iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders));
|
||||||
|
installer.verifyNoInteractions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void unrelatedEnableEventsDoNotRegisterPlaceholders() throws Exception {
|
||||||
|
Iris iris = plugin(true);
|
||||||
|
|
||||||
|
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||||
|
MockedStatic<IrisPapiInstaller> installer = mockStatic(IrisPapiInstaller.class)) {
|
||||||
|
iris.onPlaceholderApiEnable(new PluginEnableEvent(namedPlugin("Unrelated")));
|
||||||
|
installer.verifyNoInteractions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Iris plugin(boolean active) throws ReflectiveOperationException {
|
||||||
|
Iris iris = mock(Iris.class);
|
||||||
|
when(iris.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||||
|
doCallRealMethod().when(iris).onPlaceholderApiEnable(any());
|
||||||
|
doCallRealMethod().when(iris).onPlaceholderApiDisable(any());
|
||||||
|
Field lifecycle = Iris.class.getDeclaredField("papiLifecycleActive");
|
||||||
|
lifecycle.setAccessible(true);
|
||||||
|
lifecycle.setBoolean(iris, active);
|
||||||
|
return iris;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Plugin namedPlugin(String name) {
|
||||||
|
Plugin plugin = mock(Plugin.class);
|
||||||
|
when(plugin.getName()).thenReturn(name);
|
||||||
|
return plugin;
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-3
@@ -22,6 +22,7 @@ import art.arcane.iris.modded.ModdedIrisLog;
|
|||||||
import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime;
|
import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime;
|
||||||
import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime.ChunkLoad;
|
import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime.ChunkLoad;
|
||||||
import art.arcane.iris.configuration.IrisSettings;
|
import art.arcane.iris.configuration.IrisSettings;
|
||||||
|
import art.arcane.iris.world.pregen.MantleHeapPressure;
|
||||||
import art.arcane.iris.world.pregen.PregenListener;
|
import art.arcane.iris.world.pregen.PregenListener;
|
||||||
import art.arcane.iris.world.pregen.PregenMantleBackpressure;
|
import art.arcane.iris.world.pregen.PregenMantleBackpressure;
|
||||||
import art.arcane.iris.world.pregen.PregeneratorMethod;
|
import art.arcane.iris.world.pregen.PregeneratorMethod;
|
||||||
@@ -74,8 +75,8 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
|||||||
sync = configuration.sync();
|
sync = configuration.sync();
|
||||||
pauseGuard = new PauseWhenEmptyGuard(runtime);
|
pauseGuard = new PauseWhenEmptyGuard(runtime);
|
||||||
IrisSettings.IrisSettingsPregen pregen = IrisSettings.get().getPregen();
|
IrisSettings.IrisSettingsPregen pregen = IrisSettings.get().getPregen();
|
||||||
this.maxInFlight = Math.max(8, pregen.getModdedPregenInFlight());
|
this.maxInFlight = memoryLimitedConcurrency(pregen.getModdedPregenInFlight(), MantleHeapPressure.memoryBudgetBytes());
|
||||||
this.minInFlight = Math.max(4, Math.min(16, maxInFlight / 4));
|
this.minInFlight = Math.min(maxInFlight, Math.max(4, Math.min(16, maxInFlight / 4)));
|
||||||
this.semaphore = new Semaphore(maxInFlight, true);
|
this.semaphore = new Semaphore(maxInFlight, true);
|
||||||
this.adaptiveLimit = new AtomicInteger(sync ? 1 : maxInFlight);
|
this.adaptiveLimit = new AtomicInteger(sync ? 1 : maxInFlight);
|
||||||
this.timeoutSeconds = Math.max(120, pregen.getChunkLoadTimeoutSeconds());
|
this.timeoutSeconds = Math.max(120, pregen.getChunkLoadTimeoutSeconds());
|
||||||
@@ -86,7 +87,13 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
|||||||
pregen.getMantleBackpressureTimeoutMs(),
|
pregen.getMantleBackpressureTimeoutMs(),
|
||||||
() -> {
|
() -> {
|
||||||
},
|
},
|
||||||
() -> "dim=" + runtime.worldIdentity());
|
() -> "dim=" + runtime.worldIdentity(),
|
||||||
|
this::admissionCancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int memoryLimitedConcurrency(int configuredLimit, long memoryBudgetBytes) {
|
||||||
|
long memoryLimit = Math.max(1L, memoryBudgetBytes / (256L * 1024L * 1024L));
|
||||||
|
return (int) Math.min(Math.max(1, configuredLimit), memoryLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -359,12 +366,26 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
|||||||
}
|
}
|
||||||
listener.onChunkGenerating(x, z);
|
listener.onChunkGenerating(x, z);
|
||||||
try {
|
try {
|
||||||
|
while (true) {
|
||||||
synchronized (permitMonitor) {
|
synchronized (permitMonitor) {
|
||||||
while (inFlight.get() >= adaptiveLimit.get()) {
|
while (inFlight.get() >= adaptiveLimit.get()) {
|
||||||
|
if (admissionCancelled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
permitMonitor.wait(500L);
|
permitMonitor.wait(500L);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
semaphore.acquire();
|
semaphore.acquire();
|
||||||
|
if (admissionCancelled()) {
|
||||||
|
semaphore.release();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!MantleHeapPressure.overHighWater()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
semaphore.release();
|
||||||
|
backpressure.awaitHeapHeadroom();
|
||||||
|
}
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
return;
|
return;
|
||||||
@@ -400,6 +421,10 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean admissionCancelled() {
|
||||||
|
return Thread.currentThread().isInterrupted() || !runtime.isRunning();
|
||||||
|
}
|
||||||
|
|
||||||
private void markSubmitted() {
|
private void markSubmitted() {
|
||||||
int current = inFlight.incrementAndGet();
|
int current = inFlight.incrementAndGet();
|
||||||
inFlightPeak.accumulateAndGet(current, Math::max);
|
inFlightPeak.accumulateAndGet(current, Math::max);
|
||||||
|
|||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
package art.arcane.iris.modded.command;
|
||||||
|
|
||||||
|
import art.arcane.iris.configuration.IrisSettings;
|
||||||
|
import art.arcane.iris.generation.runtime.Engine;
|
||||||
|
import art.arcane.iris.world.pregen.MantleHeapPressure;
|
||||||
|
import art.arcane.iris.world.pregen.PregenListener;
|
||||||
|
import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime;
|
||||||
|
import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime.ChunkLoad;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertThrows;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
public class ModdedPregenMethodMemoryTest {
|
||||||
|
@Test
|
||||||
|
public void configuredLimitIsBoundedByMemoryWithoutRaisingSmallerRequests() {
|
||||||
|
assertEquals(4, ModdedPregenMethod.memoryLimitedConcurrency(512, 1L << 30));
|
||||||
|
assertEquals(2, ModdedPregenMethod.memoryLimitedConcurrency(2, 1L << 30));
|
||||||
|
assertEquals(1, ModdedPregenMethod.memoryLimitedConcurrency(512, 128L << 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void smallMemoryBudgetAndRisingPressureBlockRequestsUntilHeadroomReturns() throws Exception {
|
||||||
|
IrisSettings configured = new IrisSettings();
|
||||||
|
configured.getPregen().setModdedPregenInFlight(512);
|
||||||
|
NativePregenRuntime runtime = mock(NativePregenRuntime.class);
|
||||||
|
Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS);
|
||||||
|
PregenListener listener = mock(PregenListener.class);
|
||||||
|
CompletableFuture<ChunkLoad> first = new CompletableFuture<>();
|
||||||
|
CompletableFuture<ChunkLoad> second = new CompletableFuture<>();
|
||||||
|
when(runtime.isRunning()).thenReturn(true);
|
||||||
|
when(runtime.loadChunk(0, 0)).thenReturn(first);
|
||||||
|
when(runtime.loadChunk(1, 0)).thenReturn(second);
|
||||||
|
when(engine.getHeight()).thenReturn(384);
|
||||||
|
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||||
|
try (MockedStatic<IrisSettings> settings = mockStatic(IrisSettings.class);
|
||||||
|
MockedStatic<MantleHeapPressure> pressure = mockStatic(MantleHeapPressure.class)) {
|
||||||
|
settings.when(IrisSettings::get).thenReturn(configured);
|
||||||
|
pressure.when(MantleHeapPressure::memoryBudgetBytes).thenReturn(256L << 20);
|
||||||
|
ModdedPregenMethod method = new ModdedPregenMethod(new ModdedPregenMethod.Configuration(runtime, engine, false));
|
||||||
|
method.generateChunk(0, 0, listener);
|
||||||
|
CountDownLatch started = new CountDownLatch(1);
|
||||||
|
CountDownLatch pressureObserved = new CountDownLatch(1);
|
||||||
|
AtomicBoolean pressured = new AtomicBoolean();
|
||||||
|
Future<?> pending = executor.submit(() -> {
|
||||||
|
try (MockedStatic<MantleHeapPressure> workerPressure = mockStatic(MantleHeapPressure.class)) {
|
||||||
|
workerPressure.when(MantleHeapPressure::overHighWater).thenAnswer(call -> {
|
||||||
|
if (pressured.get()) {
|
||||||
|
pressureObserved.countDown();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
started.countDown();
|
||||||
|
method.generateChunk(1, 0, listener);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(started.await(5L, TimeUnit.SECONDS));
|
||||||
|
assertThrows(TimeoutException.class, () -> pending.get(100L, TimeUnit.MILLISECONDS));
|
||||||
|
verify(runtime, never()).loadChunk(1, 0);
|
||||||
|
pressured.set(true);
|
||||||
|
first.complete(new ChunkLoad(true, null));
|
||||||
|
assertTrue(pressureObserved.await(5L, TimeUnit.SECONDS));
|
||||||
|
assertThrows(TimeoutException.class, () -> pending.get(100L, TimeUnit.MILLISECONDS));
|
||||||
|
verify(runtime, never()).loadChunk(1, 0);
|
||||||
|
pressured.set(false);
|
||||||
|
pending.get(5L, TimeUnit.SECONDS);
|
||||||
|
verify(runtime).loadChunk(1, 0);
|
||||||
|
second.complete(new ChunkLoad(true, null));
|
||||||
|
verify(listener).onChunkGenerated(0, 0);
|
||||||
|
verify(listener).onChunkGenerated(1, 0);
|
||||||
|
} finally {
|
||||||
|
first.complete(new ChunkLoad(true, null));
|
||||||
|
second.complete(new ChunkLoad(true, null));
|
||||||
|
executor.shutdownNow();
|
||||||
|
assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
package art.arcane.iris.configuration;
|
package art.arcane.iris.configuration;
|
||||||
|
|
||||||
|
import art.arcane.iris.world.pregen.MantleHeapPressure;
|
||||||
import art.arcane.iris.world.IrisPaperLikeBackendMode;
|
import art.arcane.iris.world.IrisPaperLikeBackendMode;
|
||||||
import art.arcane.iris.world.IrisRuntimeSchedulerMode;
|
import art.arcane.iris.world.IrisRuntimeSchedulerMode;
|
||||||
|
|
||||||
@@ -275,7 +276,7 @@ public class IrisSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public int getEffectiveResidentTectonicPlates(int worldHeight) {
|
public int getEffectiveResidentTectonicPlates(int worldHeight) {
|
||||||
return effectiveResidentTectonicPlates(worldHeight, Runtime.getRuntime().maxMemory());
|
return effectiveResidentTectonicPlates(worldHeight, MantleHeapPressure.memoryBudgetBytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
int effectiveResidentTectonicPlates(int worldHeight, long maximumHeapBytes) {
|
int effectiveResidentTectonicPlates(int worldHeight, long maximumHeapBytes) {
|
||||||
@@ -398,6 +399,7 @@ public class IrisSettings {
|
|||||||
@Data
|
@Data
|
||||||
public static class IrisSettingsStudio {
|
public static class IrisSettingsStudio {
|
||||||
public boolean openVSCode = true;
|
public boolean openVSCode = true;
|
||||||
|
public boolean blendChanges = true;
|
||||||
public boolean disableTimeAndWeather = true;
|
public boolean disableTimeAndWeather = true;
|
||||||
public boolean entitySpawning = true;
|
public boolean entitySpawning = true;
|
||||||
public boolean autoStartDefaultStudio = false;
|
public boolean autoStartDefaultStudio = false;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import art.arcane.iris.generation.biome.IrisBiome;
|
|||||||
import art.arcane.iris.generation.mantle.CaveTerrainSnapshot;
|
import art.arcane.iris.generation.mantle.CaveTerrainSnapshot;
|
||||||
import art.arcane.iris.world.history.FloatingBiomeOverlay;
|
import art.arcane.iris.world.history.FloatingBiomeOverlay;
|
||||||
import art.arcane.iris.generation.terrain.IrisRegion;
|
import art.arcane.iris.generation.terrain.IrisRegion;
|
||||||
|
import art.arcane.iris.generation.terrain.Terrain3DColumn;
|
||||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||||
import art.arcane.volmlib.nativelib.terrain.NativeBiome;
|
import art.arcane.volmlib.nativelib.terrain.NativeBiome;
|
||||||
import art.arcane.volmlib.util.hunk.Hunk;
|
import art.arcane.volmlib.util.hunk.Hunk;
|
||||||
@@ -25,6 +26,7 @@ public class ChunkContext {
|
|||||||
private Hunk<NativeBiome> terrainBiomes;
|
private Hunk<NativeBiome> terrainBiomes;
|
||||||
private FloatingBiomeOverlay floatingBiomes;
|
private FloatingBiomeOverlay floatingBiomes;
|
||||||
private CaveTerrainSnapshot caveTerrain;
|
private CaveTerrainSnapshot caveTerrain;
|
||||||
|
private TransitionTerrainColumn[] transitionTerrain;
|
||||||
private final int x;
|
private final int x;
|
||||||
private final int z;
|
private final int z;
|
||||||
private final IrisComplex complex;
|
private final IrisComplex complex;
|
||||||
@@ -167,6 +169,17 @@ public class ChunkContext {
|
|||||||
this.caveTerrain = caveTerrain;
|
this.caveTerrain = caveTerrain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setTransitionTerrain(int localX, int localZ, Terrain3DColumn solids, int fluidHeight) {
|
||||||
|
if (transitionTerrain == null) {
|
||||||
|
transitionTerrain = new TransitionTerrainColumn[256];
|
||||||
|
}
|
||||||
|
transitionTerrain[(localZ << 4) | localX] = new TransitionTerrainColumn(solids, fluidHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TransitionTerrainColumn getTransitionTerrain(int localX, int localZ) {
|
||||||
|
return transitionTerrain == null ? null : transitionTerrain[(localZ << 4) | localX];
|
||||||
|
}
|
||||||
|
|
||||||
public void setNaturalBiome(int localX, int y, int localZ, NativeBiome biome) {
|
public void setNaturalBiome(int localX, int y, int localZ, NativeBiome biome) {
|
||||||
if (terrainBiomes != null) {
|
if (terrainBiomes != null) {
|
||||||
terrainBiomes.setRaw(localX, y, localZ, biome);
|
terrainBiomes.setRaw(localX, y, localZ, biome);
|
||||||
@@ -280,6 +293,9 @@ public class ChunkContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record TransitionTerrainColumn(Terrain3DColumn solids, int fluidHeight) {
|
||||||
|
}
|
||||||
|
|
||||||
private static final class PrefillFillTask implements Runnable {
|
private static final class PrefillFillTask implements Runnable {
|
||||||
private final ChunkedDataCache<?> dataCache;
|
private final ChunkedDataCache<?> dataCache;
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ package art.arcane.iris.generation.decoration;
|
|||||||
|
|
||||||
import art.arcane.iris.pack.loading.IrisData;
|
import art.arcane.iris.pack.loading.IrisData;
|
||||||
import art.arcane.iris.generation.runtime.Engine;
|
import art.arcane.iris.generation.runtime.Engine;
|
||||||
|
import art.arcane.iris.generation.context.ChunkContext;
|
||||||
|
import art.arcane.iris.generation.context.IrisContext;
|
||||||
import art.arcane.iris.generation.terrain.InferredType;
|
import art.arcane.iris.generation.terrain.InferredType;
|
||||||
import art.arcane.iris.generation.biome.IrisBiome;
|
import art.arcane.iris.generation.biome.IrisBiome;
|
||||||
import art.arcane.volmlib.util.hunk.Hunk;
|
import art.arcane.volmlib.util.hunk.Hunk;
|
||||||
@@ -60,7 +62,7 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
|
|||||||
@BlockCoordinates
|
@BlockCoordinates
|
||||||
public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1,
|
public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1,
|
||||||
Hunk<NativeBlockState> data, IrisBiome biome, InferredType inferredType, int height, int max) {
|
Hunk<NativeBlockState> data, IrisBiome biome, InferredType inferredType, int height, int max) {
|
||||||
int fluidHeight = getEngine().getMantle().getFluidHeight(realX, realZ);
|
int fluidHeight = surfaceFluidHeight(realX, realZ);
|
||||||
if (inferredType == InferredType.SHORE && height < fluidHeight) {
|
if (inferredType == InferredType.SHORE && height < fluidHeight) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -100,6 +102,21 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
|
|||||||
return height < fluidHeight && inferredType != InferredType.CAVE;
|
return height < fluidHeight && inferredType != InferredType.CAVE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int surfaceFluidHeight(int worldX, int worldZ) {
|
||||||
|
IrisContext current = IrisContext.get();
|
||||||
|
ChunkContext chunk = current == null ? null : current.getChunkContext();
|
||||||
|
if (chunk != null && current.getEngine() == getEngine()
|
||||||
|
&& current.getGenerationSessionId() == getEngine().getGenerationSessionId()
|
||||||
|
&& current.getGenerationSessionId() == chunk.getGenerationSessionId()
|
||||||
|
&& worldX >> 4 == chunk.getX() >> 4 && worldZ >> 4 == chunk.getZ() >> 4) {
|
||||||
|
ChunkContext.TransitionTerrainColumn transition = chunk.getTransitionTerrain(worldX & 15, worldZ & 15);
|
||||||
|
if (transition != null) {
|
||||||
|
return transition.fluidHeight();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getEngine().getMantle().getFluidHeight(worldX, worldZ);
|
||||||
|
}
|
||||||
|
|
||||||
static boolean skipsFluid(InferredType inferredType) {
|
static boolean skipsFluid(InferredType inferredType) {
|
||||||
return inferredType == InferredType.CAVE;
|
return inferredType == InferredType.CAVE;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import art.arcane.iris.spi.IrisLogging;
|
|||||||
import art.arcane.iris.spi.IrisServices;
|
import art.arcane.iris.spi.IrisServices;
|
||||||
import art.arcane.iris.generation.context.IrisContext;
|
import art.arcane.iris.generation.context.IrisContext;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
import static art.arcane.iris.generation.runtime.EngineShutdownSequence.runCleanup;
|
import static art.arcane.iris.generation.runtime.EngineShutdownSequence.runCleanup;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -224,12 +226,14 @@ final class EngineHotloader {
|
|||||||
StudioGenerationUpdate update = null;
|
StudioGenerationUpdate update = null;
|
||||||
boolean backgroundAdmissionClosed = false;
|
boolean backgroundAdmissionClosed = false;
|
||||||
try {
|
try {
|
||||||
|
long startedAt = System.nanoTime();
|
||||||
engine.requireRunning("hotload the Studio pack");
|
engine.requireRunning("hotload the Studio pack");
|
||||||
GenerationHistory history = router.history();
|
GenerationHistory history = router.history();
|
||||||
update = StudioGenerationUpdate.prepare(engine, history);
|
update = StudioGenerationUpdate.prepare(engine, history);
|
||||||
if (update == null) {
|
if (update == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
long preparedAt = System.nanoTime();
|
||||||
engine.awaitNativeStructureBootstrap("Studio generation cutover");
|
engine.awaitNativeStructureBootstrap("Studio generation cutover");
|
||||||
engine.backgroundTasks.closeBackgroundTaskAdmission();
|
engine.backgroundTasks.closeBackgroundTaskAdmission();
|
||||||
backgroundAdmissionClosed = true;
|
backgroundAdmissionClosed = true;
|
||||||
@@ -243,15 +247,27 @@ final class EngineHotloader {
|
|||||||
engine.lifecycleState = LifecycleState.HOTLOADING;
|
engine.lifecycleState = LifecycleState.HOTLOADING;
|
||||||
}
|
}
|
||||||
engine.sealForTransition("Studio generation cutover", false);
|
engine.sealForTransition("Studio generation cutover", false);
|
||||||
|
long drainedAt = System.nanoTime();
|
||||||
IrisBoundarySignatureSampler.checkpoint(engine);
|
IrisBoundarySignatureSampler.checkpoint(engine);
|
||||||
|
long checkpointedAt = System.nanoTime();
|
||||||
synchronized (engine.lifecycleLock) {
|
synchronized (engine.lifecycleLock) {
|
||||||
if (engine.lifecycleState != LifecycleState.HOTLOADING || engine.runtime != previous) {
|
if (engine.lifecycleState != LifecycleState.HOTLOADING || engine.runtime != previous) {
|
||||||
throw new IllegalStateException("Studio engine changed while its generation cutover was draining.");
|
throw new IllegalStateException("Studio engine changed while its generation cutover was draining.");
|
||||||
}
|
}
|
||||||
engine.prepareRuntimeHotload();
|
engine.prepareRuntimeHotload();
|
||||||
|
IrisSettings settings = IrisSettings.get();
|
||||||
|
int transitionWidth = settings.getStudio().isBlendChanges()
|
||||||
|
? settings.getGenerator().getGenerationTransitionWidthBlocks() : 0;
|
||||||
history.stageUpdate(update.source(), update.fingerprint(), update.dimensionContract(),
|
history.stageUpdate(update.source(), update.fingerprint(), update.dimensionContract(),
|
||||||
update.registryContract(), IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks());
|
update.registryContract(), transitionWidth);
|
||||||
|
}
|
||||||
|
long stagedAt = System.nanoTime();
|
||||||
cutover.promotePending();
|
cutover.promotePending();
|
||||||
|
long promotedAt = System.nanoTime();
|
||||||
|
synchronized (engine.lifecycleLock) {
|
||||||
|
if (engine.lifecycleState != LifecycleState.HOTLOADING) {
|
||||||
|
throw new IllegalStateException("Studio engine changed while its generation cutover was publishing.");
|
||||||
|
}
|
||||||
IrisStructureLocator.invalidate(engine);
|
IrisStructureLocator.invalidate(engine);
|
||||||
StructureReachability.invalidate(engine);
|
StructureReachability.invalidate(engine);
|
||||||
engine.runtimeBuilder.refreshStudioRuntimeServices();
|
engine.runtimeBuilder.refreshStudioRuntimeServices();
|
||||||
@@ -259,19 +275,27 @@ final class EngineHotloader {
|
|||||||
engine.getEngineData().getStatistics().hotloaded();
|
engine.getEngineData().getStatistics().hotloaded();
|
||||||
engine.getPlatformHooks().applyWorldBoundary(engine);
|
engine.getPlatformHooks().applyWorldBoundary(engine);
|
||||||
}
|
}
|
||||||
|
long completedAt = System.nanoTime();
|
||||||
|
IrisLogging.debug("Studio update timing: world=%s total=%dms prepare=%dms drain=%dms checkpoint=%dms stage=%dms activation=%dms refresh=%dms",
|
||||||
|
engine.getWorld().name(), elapsedMillis(startedAt, completedAt),
|
||||||
|
elapsedMillis(startedAt, preparedAt), elapsedMillis(preparedAt, drainedAt),
|
||||||
|
elapsedMillis(drainedAt, checkpointedAt), elapsedMillis(checkpointedAt, stagedAt),
|
||||||
|
elapsedMillis(stagedAt, promotedAt), elapsedMillis(promotedAt, completedAt));
|
||||||
broadcastStudioHotload(false, "");
|
broadcastStudioHotload(false, "");
|
||||||
} catch (Throwable failure) {
|
} catch (Throwable failure) {
|
||||||
if (previous != null) {
|
if (previous != null) {
|
||||||
synchronized (engine.lifecycleLock) {
|
synchronized (engine.lifecycleLock) {
|
||||||
if (engine.lifecycleState == LifecycleState.HOTLOADING
|
if (engine.lifecycleState == LifecycleState.HOTLOADING
|
||||||
|| engine.lifecycleState == LifecycleState.RUNNING) {
|
|| engine.lifecycleState == LifecycleState.RUNNING) {
|
||||||
boolean unchanged = false;
|
boolean activeGenerationUnchanged = false;
|
||||||
try {
|
try {
|
||||||
unchanged = router.history().manifest() == previousManifest;
|
GenerationManifest currentManifest = router.history().manifest();
|
||||||
|
activeGenerationUnchanged = currentManifest.activeActivation().activationId()
|
||||||
|
== previousManifest.activeActivation().activationId();
|
||||||
} catch (Throwable historyFailure) {
|
} catch (Throwable historyFailure) {
|
||||||
failure.addSuppressed(historyFailure);
|
failure.addSuppressed(historyFailure);
|
||||||
}
|
}
|
||||||
if (unchanged) {
|
if (activeGenerationUnchanged) {
|
||||||
engine.runtimeBuilder.restoreRuntimeAfterFailedTransition(previous);
|
engine.runtimeBuilder.restoreRuntimeAfterFailedTransition(previous);
|
||||||
} else {
|
} else {
|
||||||
engine.lifecycleState = LifecycleState.FAILED;
|
engine.lifecycleState = LifecycleState.FAILED;
|
||||||
@@ -305,6 +329,10 @@ final class EngineHotloader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static long elapsedMillis(long startedAt, long completedAt) {
|
||||||
|
return TimeUnit.NANOSECONDS.toMillis(completedAt - startedAt);
|
||||||
|
}
|
||||||
|
|
||||||
private void requireMutableGenerationRuntime(String operation) {
|
private void requireMutableGenerationRuntime(String operation) {
|
||||||
if (engine.getGenerationHistoryRuntimeRouter().isPresent()) {
|
if (engine.getGenerationHistoryRuntimeRouter().isPresent()) {
|
||||||
throw new IllegalStateException("Cannot " + operation
|
throw new IllegalStateException("Cannot " + operation
|
||||||
|
|||||||
@@ -995,7 +995,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
|||||||
zone.setCeiling(y - 1);
|
zone.setCeiling(y - 1);
|
||||||
if (zone.isValid(resolver.height)
|
if (zone.isValid(resolver.height)
|
||||||
&& (subterrainCell(worldX, floor, worldZ).occupied()
|
&& (subterrainCell(worldX, floor, worldZ).occupied()
|
||||||
|| !isNaturalTerrainOpening(getComplex(), worldX, floor, worldZ))) {
|
|| !isNaturalTerrainOpening(getComplex(), worldX, floor, worldZ, context))) {
|
||||||
if (markerRoll(worldX, zone.ceiling, worldZ, 0x9E3779B97F4A7C15L)) {
|
if (markerRoll(worldX, zone.ceiling, worldZ, 0x9E3779B97F4A7C15L)) {
|
||||||
mantle.set(worldX, zone.ceiling, worldZ, MarkerMatter.CAVE_CEILING);
|
mantle.set(worldX, zone.ceiling, worldZ, MarkerMatter.CAVE_CEILING);
|
||||||
}
|
}
|
||||||
@@ -1112,6 +1112,17 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
|
|||||||
&& !getEngine().isAdditionalTerrainOwned(x, y, z);
|
&& !getEngine().isAdditionalTerrainOwned(x, y, z);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isNaturalTerrainOpening(IrisComplex complex, int x, int y, int z, ChunkContext context) {
|
||||||
|
ChunkContext.TransitionTerrainColumn transition = context == null ? null
|
||||||
|
: context.getTransitionTerrain(x & 15, z & 15);
|
||||||
|
if (transition == null) {
|
||||||
|
return isNaturalTerrainOpening(complex, x, y, z);
|
||||||
|
}
|
||||||
|
Terrain3DColumn natural = complex.terrainColumn(x, z, complex.sampleHydrologyColumn(x, z));
|
||||||
|
return natural != null && IrisDecorantActuator.isTransitionTerrainOpening(natural, transition, y)
|
||||||
|
&& !getEngine().isAdditionalTerrainOwned(x, y, z);
|
||||||
|
}
|
||||||
|
|
||||||
/** The dimension's underground river bed palette, or null when nothing paints. */
|
/** The dimension's underground river bed palette, or null when nothing paints. */
|
||||||
private IrisRiverMaterialConfig undergroundBedMaterial() {
|
private IrisRiverMaterialConfig undergroundBedMaterial() {
|
||||||
IrisHydrology hydrology = getDimension().getHydrology();
|
IrisHydrology hydrology = getDimension().getHydrology();
|
||||||
|
|||||||
@@ -174,8 +174,9 @@ public class IrisDecorantActuator extends EngineAssignedActuator<NativeBlockStat
|
|||||||
if (isFallingWaterfallThroat(hydrology)) {
|
if (isFallingWaterfallThroat(hydrology)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
int surfaceFluidHeight = (int) Math.round(
|
ChunkContext.TransitionTerrainColumn transitionTerrain = context.getTransitionTerrain(i, j);
|
||||||
getComplex().getRiverWaterSurfaceStream().get(realX, realZ));
|
int surfaceFluidHeight = transitionTerrain == null ? (int) Math.round(
|
||||||
|
getComplex().getRiverWaterSurfaceStream().get(realX, realZ)) : transitionTerrain.fluidHeight();
|
||||||
|
|
||||||
if (biome.getDecorators().isEmpty() && (cave == null || cave.getDecorators().isEmpty())) {
|
if (biome.getDecorators().isEmpty() && (cave == null || cave.getDecorators().isEmpty())) {
|
||||||
continue;
|
continue;
|
||||||
@@ -231,7 +232,7 @@ public class IrisDecorantActuator extends EngineAssignedActuator<NativeBlockStat
|
|||||||
|
|
||||||
Terrain3DColumn terrainColumn = getComplex().terrainColumn(realX, realZ, hydrology);
|
Terrain3DColumn terrainColumn = getComplex().terrainColumn(realX, realZ, hydrology);
|
||||||
if (terrainColumn != null) {
|
if (terrainColumn != null) {
|
||||||
decorateTerrainLedges(output, terrainColumn, biome, i, j, realX, realZ);
|
decorateTerrainLedges(output, terrainColumn, transitionTerrain, biome, i, j, realX, realZ);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -263,10 +264,15 @@ public class IrisDecorantActuator extends EngineAssignedActuator<NativeBlockStat
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void decorateTerrainLedges(Hunk<NativeBlockState> output, Terrain3DColumn column,
|
private void decorateTerrainLedges(Hunk<NativeBlockState> output, Terrain3DColumn column,
|
||||||
|
ChunkContext.TransitionTerrainColumn transition,
|
||||||
IrisBiome biome, int localX, int localZ, int worldX, int worldZ) {
|
IrisBiome biome, int localX, int localZ, int worldX, int worldZ) {
|
||||||
for (int span = 0; span + 1 < column.spanCount(); span++) {
|
Terrain3DColumn ledges = transition == null ? column : transition.solids();
|
||||||
int floorY = column.floor(span);
|
for (int span = 0; span + 1 < ledges.spanCount(); span++) {
|
||||||
int ceilingY = column.ceiling(span + 1);
|
int floorY = ledges.floor(span);
|
||||||
|
int ceilingY = ledges.ceiling(span + 1);
|
||||||
|
if (transition != null && resolveTerrainLedge(column, span, transition) == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
int headroom = ceilingY - floorY - 1;
|
int headroom = ceilingY - floorY - 1;
|
||||||
if (headroom < 1 || !PREDICATE_SOLID.test(output.getRaw(localX, floorY, localZ))) {
|
if (headroom < 1 || !PREDICATE_SOLID.test(output.getRaw(localX, floorY, localZ))) {
|
||||||
continue;
|
continue;
|
||||||
@@ -280,6 +286,37 @@ public class IrisDecorantActuator extends EngineAssignedActuator<NativeBlockStat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static TerrainLedge resolveTerrainLedge(Terrain3DColumn natural, int span,
|
||||||
|
ChunkContext.TransitionTerrainColumn transition) {
|
||||||
|
if (transition == null) {
|
||||||
|
return new TerrainLedge(natural.floor(span), natural.ceiling(span + 1));
|
||||||
|
}
|
||||||
|
Terrain3DColumn blended = transition.solids();
|
||||||
|
int blendedFloor = blended.floor(span);
|
||||||
|
int blendedCeiling = blended.ceiling(span + 1);
|
||||||
|
for (int candidate = 0; candidate + 1 < natural.spanCount(); candidate++) {
|
||||||
|
if (Math.max(natural.floor(candidate), blendedFloor) + 1
|
||||||
|
< Math.min(natural.ceiling(candidate + 1), blendedCeiling)) {
|
||||||
|
return new TerrainLedge(blendedFloor, blendedCeiling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isTransitionTerrainOpening(Terrain3DColumn natural,
|
||||||
|
ChunkContext.TransitionTerrainColumn transition, int y) {
|
||||||
|
Terrain3DColumn blended = transition.solids();
|
||||||
|
for (int span = 0; span + 1 < blended.spanCount(); span++) {
|
||||||
|
if (y > blended.floor(span) && y < blended.ceiling(span + 1)) {
|
||||||
|
return resolveTerrainLedge(natural, span, transition) != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
record TerrainLedge(int floorY, int ceilingY) {
|
||||||
|
}
|
||||||
|
|
||||||
enum ShorelineDecorationMode {
|
enum ShorelineDecorationMode {
|
||||||
NONE,
|
NONE,
|
||||||
LEGACY,
|
LEGACY,
|
||||||
|
|||||||
+12
@@ -3,6 +3,7 @@ package art.arcane.iris.generation.stage;
|
|||||||
import art.arcane.iris.generation.runtime.Engine;
|
import art.arcane.iris.generation.runtime.Engine;
|
||||||
import art.arcane.iris.generation.runtime.EngineAssignedComponent;
|
import art.arcane.iris.generation.runtime.EngineAssignedComponent;
|
||||||
import art.arcane.iris.generation.runtime.EngineStage;
|
import art.arcane.iris.generation.runtime.EngineStage;
|
||||||
|
import art.arcane.iris.generation.terrain.Terrain3DColumn;
|
||||||
import art.arcane.iris.world.history.BoundaryColumnGeometry;
|
import art.arcane.iris.world.history.BoundaryColumnGeometry;
|
||||||
import art.arcane.iris.world.history.BoundaryGeometryInfluence;
|
import art.arcane.iris.world.history.BoundaryGeometryInfluence;
|
||||||
import art.arcane.iris.world.history.SavedTerrainChunk;
|
import art.arcane.iris.world.history.SavedTerrainChunk;
|
||||||
@@ -85,6 +86,17 @@ public final class IrisTransitionGeometryActuator extends EngineAssignedComponen
|
|||||||
blocks.setRaw(localX, offset, localZ, replacement);
|
blocks.setRaw(localX, offset, localZ, replacement);
|
||||||
}
|
}
|
||||||
context.setTerrainHeight(localX, localZ, floor);
|
context.setTerrainHeight(localX, localZ, floor);
|
||||||
|
int fluidHeight = floor;
|
||||||
|
while (fluidHeight + 1 < voxels.size()
|
||||||
|
&& voxels.get(fluidHeight + 1).phase() == BoundaryColumnGeometry.Phase.FLUID) {
|
||||||
|
fluidHeight++;
|
||||||
|
}
|
||||||
|
if (fluidHeight == floor) {
|
||||||
|
fluidHeight = -1;
|
||||||
|
}
|
||||||
|
context.setTransitionTerrain(localX, localZ, Terrain3DColumn.fromOccupancy(
|
||||||
|
floor, voxels.size(), offset -> voxels.get(offset).phase() == BoundaryColumnGeometry.Phase.SOLID),
|
||||||
|
fluidHeight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
|||||||
private final AtomicReference<CompletableFuture<Void>> watcherHotload = new AtomicReference<>();
|
private final AtomicReference<CompletableFuture<Void>> watcherHotload = new AtomicReference<>();
|
||||||
private volatile Engine engine;
|
private volatile Engine engine;
|
||||||
private volatile Looper hotloader;
|
private volatile Looper hotloader;
|
||||||
|
private volatile boolean studioUpdatesPaused;
|
||||||
private volatile StudioMode lastMode;
|
private volatile StudioMode lastMode;
|
||||||
private volatile UUID lastJigsawStudioRequestId;
|
private volatile UUID lastJigsawStudioRequestId;
|
||||||
private volatile boolean jigsawStudioActive;
|
private volatile boolean jigsawStudioActive;
|
||||||
@@ -978,6 +979,18 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
|||||||
return future;
|
return future;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletableFuture<Void> quiesceStudioUpdatesAsync() {
|
||||||
|
studioUpdatesPaused = true;
|
||||||
|
cancelWatcherHotload();
|
||||||
|
Looper activeHotloader = hotloader;
|
||||||
|
hotloader = null;
|
||||||
|
if (activeHotloader != null) {
|
||||||
|
activeHotloader.interrupt();
|
||||||
|
}
|
||||||
|
return withExclusiveControlFuture(() -> { });
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void quiesceForServerShutdown() {
|
public void quiesceForServerShutdown() {
|
||||||
cancelWatcherHotload();
|
cancelWatcherHotload();
|
||||||
@@ -1071,26 +1084,33 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void hotload() {
|
public void hotload() {
|
||||||
if (initialEntryPending || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) {
|
if (initialEntryPending || studioUpdatesPaused || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
withExclusiveControl(() -> getEngine().hotload());
|
withExclusiveControl(() -> {
|
||||||
|
if (!studioUpdatesPaused && !closing) {
|
||||||
|
getEngine().hotload();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void hotloadFromWatcher() {
|
private void hotloadFromWatcher() {
|
||||||
if (initialEntryPending || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) {
|
if (initialEntryPending || studioUpdatesPaused || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
CompletableFuture<Void> pending = new CompletableFuture<>();
|
CompletableFuture<Void> pending = new CompletableFuture<>();
|
||||||
|
long queuedAt = System.nanoTime();
|
||||||
watcherHotload.set(pending);
|
watcherHotload.set(pending);
|
||||||
try {
|
try {
|
||||||
if (initialEntryPending || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)
|
if (initialEntryPending || studioUpdatesPaused || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)
|
||||||
|| Thread.currentThread().isInterrupted()) {
|
|| Thread.currentThread().isInterrupted()) {
|
||||||
pending.cancel(true);
|
pending.cancel(true);
|
||||||
} else {
|
} else {
|
||||||
J.a(() -> completeExclusiveControlFuture(loadLock, () -> {
|
J.a(() -> completeExclusiveControlFuture(loadLock, () -> {
|
||||||
if (!initialEntryPending && shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) {
|
if (!initialEntryPending && !studioUpdatesPaused && shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) {
|
||||||
|
IrisLogging.debug("Studio update admission: world=%s wait=%dms",
|
||||||
|
getTarget().getWorld().name(), TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - queuedAt));
|
||||||
getEngine().hotload();
|
getEngine().hotload();
|
||||||
}
|
}
|
||||||
}, pending));
|
}, pending));
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ public interface PlatformChunkGenerator extends Hotloadable, DataProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CompletableFuture<Void> quiesceStudioUpdatesAsync();
|
||||||
|
|
||||||
default void quiesceForServerShutdown() {
|
default void quiesceForServerShutdown() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -483,6 +483,7 @@ public final class StudioOpenCoordinator {
|
|||||||
IrisToolbelt.beginWorldMaintenance(world, "studio-close", true);
|
IrisToolbelt.beginWorldMaintenance(world, "studio-close", true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
() -> provider == null ? CompletableFuture.completedFuture(null) : provider.quiesceStudioUpdatesAsync(),
|
||||||
() -> unloadWorldFamily(worldName, world).thenRun(() -> unloadConfirmed.set(true)),
|
() -> unloadWorldFamily(worldName, world).thenRun(() -> unloadConfirmed.set(true)),
|
||||||
() -> provider == null ? CompletableFuture.completedFuture(null) : provider.closeAsync(),
|
() -> provider == null ? CompletableFuture.completedFuture(null) : provider.closeAsync(),
|
||||||
() -> deleteFolder
|
() -> deleteFolder
|
||||||
@@ -516,6 +517,7 @@ public final class StudioOpenCoordinator {
|
|||||||
static CompletableFuture<Void> sequenceStudioClose(
|
static CompletableFuture<Void> sequenceStudioClose(
|
||||||
Supplier<CompletableFuture<Void>> evacuate,
|
Supplier<CompletableFuture<Void>> evacuate,
|
||||||
Runnable enterMaintenance,
|
Runnable enterMaintenance,
|
||||||
|
Supplier<CompletableFuture<Void>> quiesceUpdates,
|
||||||
Supplier<CompletableFuture<Void>> unload,
|
Supplier<CompletableFuture<Void>> unload,
|
||||||
Supplier<CompletableFuture<Void>> closeGenerator,
|
Supplier<CompletableFuture<Void>> closeGenerator,
|
||||||
Supplier<CompletableFuture<Void>> deleteFolders
|
Supplier<CompletableFuture<Void>> deleteFolders
|
||||||
@@ -523,8 +525,9 @@ public final class StudioOpenCoordinator {
|
|||||||
return invokePhase(evacuate)
|
return invokePhase(evacuate)
|
||||||
.thenCompose(ignored -> {
|
.thenCompose(ignored -> {
|
||||||
enterMaintenance.run();
|
enterMaintenance.run();
|
||||||
return invokePhase(unload);
|
return invokePhase(quiesceUpdates);
|
||||||
})
|
})
|
||||||
|
.thenCompose(ignored -> invokePhase(unload))
|
||||||
.thenCompose(ignored -> invokePhase(closeGenerator))
|
.thenCompose(ignored -> invokePhase(closeGenerator))
|
||||||
.thenCompose(ignored -> invokePhase(deleteFolders));
|
.thenCompose(ignored -> invokePhase(deleteFolders));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,10 @@ package art.arcane.iris.studio.workspace;
|
|||||||
|
|
||||||
import art.arcane.iris.spi.IrisLogging;
|
import art.arcane.iris.spi.IrisLogging;
|
||||||
import art.arcane.iris.studio.object.StudioOpenCoordinator;
|
import art.arcane.iris.studio.object.StudioOpenCoordinator;
|
||||||
import art.arcane.iris.world.IrisToolbelt;
|
|
||||||
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
|
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
|
||||||
import art.arcane.volmlib.util.collection.KList;
|
import art.arcane.volmlib.util.collection.KList;
|
||||||
import art.arcane.volmlib.util.exceptions.IrisException;
|
import art.arcane.volmlib.util.exceptions.IrisException;
|
||||||
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
|
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
|
||||||
import art.arcane.iris.world.task.J;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import org.bukkit.World;
|
import org.bukkit.World;
|
||||||
|
|
||||||
@@ -136,8 +134,6 @@ public class IrisProject {
|
|||||||
);
|
);
|
||||||
StudioOpenProgressReporter.startStudioOpenReporter(sender, stage, progress, complete, failed);
|
StudioOpenProgressReporter.startStudioOpenReporter(sender, stage, progress, complete, failed);
|
||||||
future.whenComplete((result, throwable) -> {
|
future.whenComplete((result, throwable) -> {
|
||||||
World maintenanceWorld = null;
|
|
||||||
boolean maintenanceActive = false;
|
|
||||||
try {
|
try {
|
||||||
if (throwable != null) {
|
if (throwable != null) {
|
||||||
failed.set(true);
|
failed.set(true);
|
||||||
@@ -148,17 +144,7 @@ public class IrisProject {
|
|||||||
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_PROJECT_STUDIO_OPEN_FAILED, MessageArgument.untrusted("error", String.valueOf(error.getMessage()))));
|
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_PROJECT_STUDIO_OPEN_FAILED, MessageArgument.untrusted("error", String.valueOf(error.getMessage()))));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
maintenanceWorld = result.world();
|
|
||||||
if (maintenanceWorld != null) {
|
|
||||||
IrisToolbelt.beginWorldMaintenance(maintenanceWorld, "studio-open");
|
|
||||||
maintenanceActive = true;
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (maintenanceActive && maintenanceWorld != null) {
|
|
||||||
World worldToRelease = maintenanceWorld;
|
|
||||||
J.a(() -> IrisToolbelt.endWorldMaintenance(worldToRelease, "studio-open"), 300);
|
|
||||||
}
|
|
||||||
complete.set(true);
|
complete.set(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -365,13 +365,12 @@ public class IrisCreator {
|
|||||||
worldKey,
|
worldKey,
|
||||||
IrisWorldStorage.levelRoot().getName()
|
IrisWorldStorage.levelRoot().getName()
|
||||||
);
|
);
|
||||||
BukkitWorldConfiguration.register(
|
bukkitRegistered = BukkitWorldConfiguration.register(
|
||||||
BUKKIT_YML,
|
BUKKIT_YML,
|
||||||
registrationName,
|
registrationName,
|
||||||
dimension,
|
dimension,
|
||||||
seed
|
seed
|
||||||
);
|
) == BukkitWorldConfiguration.Registration.CREATED;
|
||||||
bukkitRegistered = true;
|
|
||||||
World createdWorld = world;
|
World createdWorld = world;
|
||||||
CompletableFuture<Void> multiverseRegistration = J.sfut(
|
CompletableFuture<Void> multiverseRegistration = J.sfut(
|
||||||
() -> IrisServices.get(MultiverseCoreLink.class)
|
() -> IrisServices.get(MultiverseCoreLink.class)
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ public record GenerationTransition(
|
|||||||
if (algorithmVersion < 1) {
|
if (algorithmVersion < 1) {
|
||||||
throw new IllegalArgumentException("Transition algorithm version must be positive.");
|
throw new IllegalArgumentException("Transition algorithm version must be positive.");
|
||||||
}
|
}
|
||||||
if (widthBlocks < MINIMUM_WIDTH_BLOCKS || widthBlocks > MAXIMUM_WIDTH_BLOCKS) {
|
if (widthBlocks != 0 && (widthBlocks < MINIMUM_WIDTH_BLOCKS || widthBlocks > MAXIMUM_WIDTH_BLOCKS)) {
|
||||||
throw new IllegalArgumentException("Transition width must be between "
|
throw new IllegalArgumentException("Transition width must be zero or between "
|
||||||
+ MINIMUM_WIDTH_BLOCKS + " and " + MAXIMUM_WIDTH_BLOCKS + " blocks.");
|
+ MINIMUM_WIDTH_BLOCKS + " and " + MAXIMUM_WIDTH_BLOCKS + " blocks.");
|
||||||
}
|
}
|
||||||
if ((boundaryIdentity == null) != (terrainSignatureIdentity == null)) {
|
if ((boundaryIdentity == null) != (terrainSignatureIdentity == null)) {
|
||||||
|
|||||||
+48
-18
@@ -4,7 +4,11 @@ import art.arcane.iris.generation.runtime.IrisEngine;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.CancellationException;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.TimeoutException;
|
import java.util.concurrent.TimeoutException;
|
||||||
@@ -47,43 +51,69 @@ public final class IrisBoundarySignatureSampler implements GenerationBoundarySig
|
|||||||
|
|
||||||
private static final class LiveCapture implements TerrainBoundarySignatureStore.SignatureSampler {
|
private static final class LiveCapture implements TerrainBoundarySignatureStore.SignatureSampler {
|
||||||
private final IrisEngine engine;
|
private final IrisEngine engine;
|
||||||
private final LinkedHashMap<Long, SavedTerrainChunk> chunks = new LinkedHashMap<>(32, 0.75F, true);
|
private final LinkedHashMap<Long, CompletableFuture<SavedTerrainChunk>> chunks = new LinkedHashMap<>(32, 0.75F, true);
|
||||||
|
|
||||||
private LiveCapture(IrisEngine engine) {
|
private LiveCapture(IrisEngine engine) {
|
||||||
this.engine = engine;
|
this.engine = engine;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void prepareChunks(List<GenerationBoundary.ChunkCoordinate> coordinates) {
|
||||||
|
if (coordinates.size() > MAXIMUM_CACHED_CHUNKS) {
|
||||||
|
throw new IllegalArgumentException("Terrain capture batches cannot exceed 32 chunks.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (GenerationBoundary.ChunkCoordinate coordinate : coordinates) {
|
||||||
|
captureChunk(coordinate.chunkX(), coordinate.chunkZ());
|
||||||
|
}
|
||||||
|
} catch (RuntimeException | Error failure) {
|
||||||
|
cancelPendingCaptures();
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TerrainBoundarySignature sample(int blockX, int blockZ) throws IOException {
|
public TerrainBoundarySignature sample(int blockX, int blockZ) throws IOException {
|
||||||
int chunkX = Math.floorDiv(blockX, 16);
|
int chunkX = Math.floorDiv(blockX, 16);
|
||||||
int chunkZ = Math.floorDiv(blockZ, 16);
|
int chunkZ = Math.floorDiv(blockZ, 16);
|
||||||
long key = ChunkGenerationOwnership.packChunk(chunkX, chunkZ);
|
|
||||||
SavedTerrainChunk terrain = chunks.get(key);
|
|
||||||
if (terrain == null) {
|
|
||||||
terrain = captureChunk(chunkX, chunkZ);
|
|
||||||
chunks.put(key, terrain);
|
|
||||||
if (chunks.size() > MAXIMUM_CACHED_CHUNKS) {
|
|
||||||
Map.Entry<Long, SavedTerrainChunk> oldest = chunks.firstEntry();
|
|
||||||
chunks.remove(oldest.getKey());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return terrain.column(blockX, blockZ);
|
|
||||||
}
|
|
||||||
|
|
||||||
private SavedTerrainChunk captureChunk(int chunkX, int chunkZ) throws IOException {
|
|
||||||
try {
|
try {
|
||||||
return engine.getPlatformHooks().captureSavedTerrainChunk(engine, chunkX, chunkZ)
|
return captureChunk(chunkX, chunkZ).get(30, TimeUnit.SECONDS).column(blockX, blockZ);
|
||||||
.get(30, TimeUnit.SECONDS);
|
|
||||||
} catch (InterruptedException failure) {
|
} catch (InterruptedException failure) {
|
||||||
|
cancelPendingCaptures();
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
throw new IOException("Interrupted while capturing terrain at " + chunkX + "," + chunkZ, failure);
|
throw new IOException("Interrupted while capturing terrain at " + chunkX + "," + chunkZ, failure);
|
||||||
} catch (ExecutionException | TimeoutException failure) {
|
} catch (ExecutionException | TimeoutException | CancellationException failure) {
|
||||||
|
cancelPendingCaptures();
|
||||||
throw new IOException("Unable to capture terrain at " + chunkX + "," + chunkZ, failure);
|
throw new IOException("Unable to capture terrain at " + chunkX + "," + chunkZ, failure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<SavedTerrainChunk> captureChunk(int chunkX, int chunkZ) {
|
||||||
|
long key = ChunkGenerationOwnership.packChunk(chunkX, chunkZ);
|
||||||
|
CompletableFuture<SavedTerrainChunk> terrain = chunks.get(key);
|
||||||
|
if (terrain != null) {
|
||||||
|
return terrain;
|
||||||
|
}
|
||||||
|
terrain = Objects.requireNonNull(engine.getPlatformHooks().captureSavedTerrainChunk(engine, chunkX, chunkZ),
|
||||||
|
"Terrain capture future");
|
||||||
|
chunks.put(key, terrain);
|
||||||
|
if (chunks.size() > MAXIMUM_CACHED_CHUNKS) {
|
||||||
|
Map.Entry<Long, CompletableFuture<SavedTerrainChunk>> oldest = chunks.firstEntry();
|
||||||
|
chunks.remove(oldest.getKey());
|
||||||
|
oldest.getValue().cancel(false);
|
||||||
|
}
|
||||||
|
return terrain;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cancelPendingCaptures() {
|
||||||
|
for (CompletableFuture<SavedTerrainChunk> capture : chunks.values()) {
|
||||||
|
capture.cancel(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() throws IOException {
|
public void close() throws IOException {
|
||||||
|
cancelPendingCaptures();
|
||||||
try {
|
try {
|
||||||
checkpoint(engine);
|
checkpoint(engine);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+36
-1
@@ -66,6 +66,7 @@ public final class TerrainBoundarySignatureStore {
|
|||||||
private static final int SHARD_FIXED_BODY_BYTES = 12;
|
private static final int SHARD_FIXED_BODY_BYTES = 12;
|
||||||
private static final int CHECKSUM_BYTES = Integer.BYTES;
|
private static final int CHECKSUM_BYTES = Integer.BYTES;
|
||||||
private static final int CELL_SIZE = 32;
|
private static final int CELL_SIZE = 32;
|
||||||
|
private static final int PREPARED_CELL_WINDOW = 8;
|
||||||
private static final int SUPER_CELL_SIZE = 32;
|
private static final int SUPER_CELL_SIZE = 32;
|
||||||
private static final int MAXIMUM_CACHED_SHARDS = 64;
|
private static final int MAXIMUM_CACHED_SHARDS = 64;
|
||||||
private static final int MAX_CELL_ENTRIES = CELL_SIZE * CELL_SIZE;
|
private static final int MAX_CELL_ENTRIES = CELL_SIZE * CELL_SIZE;
|
||||||
@@ -148,7 +149,16 @@ public final class TerrainBoundarySignatureStore {
|
|||||||
LongArrays.quickSort(cellKeys, TerrainBoundarySignatureStore::compareCoordinates);
|
LongArrays.quickSort(cellKeys, TerrainBoundarySignatureStore::compareCoordinates);
|
||||||
Long2ObjectOpenHashMap<CellReference> cells = new Long2ObjectOpenHashMap<>(cellKeys.length);
|
Long2ObjectOpenHashMap<CellReference> cells = new Long2ObjectOpenHashMap<>(cellKeys.length);
|
||||||
int count = 0;
|
int count = 0;
|
||||||
for (long cellKey : cellKeys) {
|
for (int offset = 0; offset < cellKeys.length; offset += PREPARED_CELL_WINDOW) {
|
||||||
|
int end = Math.min(cellKeys.length, offset + PREPARED_CELL_WINDOW);
|
||||||
|
List<GenerationBoundary.ChunkCoordinate> chunks = new ArrayList<>((end - offset) * 4);
|
||||||
|
for (int index = offset; index < end; index++) {
|
||||||
|
long cellKey = cellKeys[index];
|
||||||
|
addSampledChunks(cellKey, columns.get(cellKey), chunks);
|
||||||
|
}
|
||||||
|
sampler.prepareChunks(List.copyOf(chunks));
|
||||||
|
for (int cellIndex = offset; cellIndex < end; cellIndex++) {
|
||||||
|
long cellKey = cellKeys[cellIndex];
|
||||||
BitSet mask = columns.remove(cellKey);
|
BitSet mask = columns.remove(cellKey);
|
||||||
List<TerrainBoundarySignature> signatures = new ArrayList<>(mask.cardinality());
|
List<TerrainBoundarySignature> signatures = new ArrayList<>(mask.cardinality());
|
||||||
for (int index = mask.nextSetBit(0); index >= 0; index = mask.nextSetBit(index + 1)) {
|
for (int index = mask.nextSetBit(0); index >= 0; index = mask.nextSetBit(index + 1)) {
|
||||||
@@ -167,6 +177,7 @@ public final class TerrainBoundarySignatureStore {
|
|||||||
cells.put(cellKey, new CellReference(hash, signatures.size()));
|
cells.put(cellKey, new CellReference(hash, signatures.size()));
|
||||||
count = Math.addExact(count, signatures.size());
|
count = Math.addExact(count, signatures.size());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
byte[] identity = computeIdentity(activationId, count, cellKeys, cells);
|
byte[] identity = computeIdentity(activationId, count, cellKeys, cells);
|
||||||
return publishCatalog(activationId, new PublishedCatalog(
|
return publishCatalog(activationId, new PublishedCatalog(
|
||||||
toHex(identity), encodeCatalog(activationId, count, cellKeys, cells, identity)
|
toHex(identity), encodeCatalog(activationId, count, cellKeys, cells, identity)
|
||||||
@@ -174,6 +185,27 @@ public final class TerrainBoundarySignatureStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void addSampledChunks(long cellKey, BitSet columns,
|
||||||
|
List<GenerationBoundary.ChunkCoordinate> chunks) {
|
||||||
|
int minimumX = Math.multiplyExact(cellX(cellKey), CELL_SIZE);
|
||||||
|
int minimumZ = Math.multiplyExact(cellZ(cellKey), CELL_SIZE);
|
||||||
|
int chunksPerSide = CELL_SIZE / GenerationBoundary.CHUNK_SIZE;
|
||||||
|
int prepared = 0;
|
||||||
|
for (int index = columns.nextSetBit(0); index >= 0; index = columns.nextSetBit(index + 1)) {
|
||||||
|
int localX = index / CELL_SIZE;
|
||||||
|
int localZ = index % CELL_SIZE;
|
||||||
|
int chunkIndex = localX / GenerationBoundary.CHUNK_SIZE * chunksPerSide
|
||||||
|
+ localZ / GenerationBoundary.CHUNK_SIZE;
|
||||||
|
int chunkBit = 1 << chunkIndex;
|
||||||
|
if ((prepared & chunkBit) == 0) {
|
||||||
|
prepared |= chunkBit;
|
||||||
|
chunks.add(new GenerationBoundary.ChunkCoordinate(
|
||||||
|
Math.floorDiv(Math.addExact(minimumX, localX), GenerationBoundary.CHUNK_SIZE),
|
||||||
|
Math.floorDiv(Math.addExact(minimumZ, localZ), GenerationBoundary.CHUNK_SIZE)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Snapshot publishCatalog(long activationId, PublishedCatalog catalog) throws IOException {
|
private Snapshot publishCatalog(long activationId, PublishedCatalog catalog) throws IOException {
|
||||||
forceDirectory(directory);
|
forceDirectory(directory);
|
||||||
Path target = snapshotPath(activationId);
|
Path target = snapshotPath(activationId);
|
||||||
@@ -678,6 +710,9 @@ public final class TerrainBoundarySignatureStore {
|
|||||||
public interface SignatureSampler extends AutoCloseable {
|
public interface SignatureSampler extends AutoCloseable {
|
||||||
TerrainBoundarySignature sample(int blockX, int blockZ) throws IOException;
|
TerrainBoundarySignature sample(int blockX, int blockZ) throws IOException;
|
||||||
|
|
||||||
|
default void prepareChunks(List<GenerationBoundary.ChunkCoordinate> chunks) throws IOException {
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
default void close() throws IOException {
|
default void close() throws IOException {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ public final class TransitionGenerationPlan {
|
|||||||
throw new IllegalArgumentException("Unsupported generation transition algorithm version "
|
throw new IllegalArgumentException("Unsupported generation transition algorithm version "
|
||||||
+ specification.algorithmVersion());
|
+ specification.algorithmVersion());
|
||||||
}
|
}
|
||||||
terrainSampler = new TransitionBoundarySampler(
|
terrainSampler = specification.widthBlocks() == 0 ? null : new TransitionBoundarySampler(
|
||||||
specification.widthBlocks(),
|
specification.widthBlocks(),
|
||||||
terrainSignatures
|
terrainSignatures
|
||||||
);
|
);
|
||||||
terrainBand = new TransitionBandIndex(boundary, terrainSampler);
|
terrainBand = terrainSampler == null ? null : new TransitionBandIndex(boundary, terrainSampler);
|
||||||
newTerrain = TerrainSample.newTerrain(specification.widthBlocks());
|
newTerrain = TerrainSample.newTerrain(specification.widthBlocks());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,11 +97,11 @@ public final class TransitionGenerationPlan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean hasTransitionAtChunk(int chunkX, int chunkZ) {
|
public boolean hasTransitionAtChunk(int chunkX, int chunkZ) {
|
||||||
return terrainBand.contains(chunkX, chunkZ);
|
return terrainBand != null && terrainBand.contains(chunkX, chunkZ);
|
||||||
}
|
}
|
||||||
|
|
||||||
public BoundaryGeometryInfluence geometryAt(int blockX, int blockZ) {
|
public BoundaryGeometryInfluence geometryAt(int blockX, int blockZ) {
|
||||||
if (!terrainBand.contains(blockX >> 4, blockZ >> 4)) {
|
if (!hasTransitionAtChunk(blockX >> 4, blockZ >> 4)) {
|
||||||
return BoundaryGeometryInfluence.none();
|
return BoundaryGeometryInfluence.none();
|
||||||
}
|
}
|
||||||
return terrainSampler.geometryAt(blockX, blockZ);
|
return terrainSampler.geometryAt(blockX, blockZ);
|
||||||
@@ -111,7 +111,7 @@ public final class TransitionGenerationPlan {
|
|||||||
if (boundary.isHistoricalBlock(blockX, blockZ)) {
|
if (boundary.isHistoricalBlock(blockX, blockZ)) {
|
||||||
return TerrainSample.historicalTerrain();
|
return TerrainSample.historicalTerrain();
|
||||||
}
|
}
|
||||||
return terrainBand.contains(blockX >> 4, blockZ >> 4) ? terrainSampler.sample(blockX, blockZ) : newTerrain;
|
return hasTransitionAtChunk(blockX >> 4, blockZ >> 4) ? terrainSampler.sample(blockX, blockZ) : newTerrain;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<String> historicalPhysicalBiomeKeyAt(int blockX, int blockY, int blockZ) {
|
public Optional<String> historicalPhysicalBiomeKeyAt(int blockX, int blockY, int blockZ) {
|
||||||
@@ -137,15 +137,15 @@ public final class TransitionGenerationPlan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
long terrainCatalogProbeCount() {
|
long terrainCatalogProbeCount() {
|
||||||
return terrainSampler.catalogProbeCount();
|
return terrainSampler == null ? 0L : terrainSampler.catalogProbeCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
long terrainShardLoadCount() {
|
long terrainShardLoadCount() {
|
||||||
return terrainSampler.shardLoadCount();
|
return terrainSampler == null ? 0L : terrainSampler.shardLoadCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
long terrainCandidateBuildCount() {
|
long terrainCandidateBuildCount() {
|
||||||
return terrainSampler.candidateBuildCount();
|
return terrainSampler == null ? 0L : terrainSampler.candidateBuildCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
public record Specification(
|
public record Specification(
|
||||||
@@ -171,8 +171,8 @@ public final class TransitionGenerationPlan {
|
|||||||
if (oldEpochId.equals(newEpochId)) {
|
if (oldEpochId.equals(newEpochId)) {
|
||||||
throw new IllegalArgumentException("Transition epoch IDs must differ");
|
throw new IllegalArgumentException("Transition epoch IDs must differ");
|
||||||
}
|
}
|
||||||
if (widthBlocks <= 0) {
|
if (widthBlocks < 0) {
|
||||||
throw new IllegalArgumentException("Transition width must be positive");
|
throw new IllegalArgumentException("Transition width cannot be negative");
|
||||||
}
|
}
|
||||||
if (algorithmVersion < 1) {
|
if (algorithmVersion < 1) {
|
||||||
throw new IllegalArgumentException("Transition algorithm version must be positive");
|
throw new IllegalArgumentException("Transition algorithm version must be positive");
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
|||||||
this.chunkAccessMode = chunkAsyncMethodSelection.mode();
|
this.chunkAccessMode = chunkAsyncMethodSelection.mode();
|
||||||
int detectedWorkerPoolThreads = resolveWorkerPoolThreads();
|
int detectedWorkerPoolThreads = resolveWorkerPoolThreads();
|
||||||
int detectedCpuThreads = Math.max(1, Runtime.getRuntime().availableProcessors());
|
int detectedCpuThreads = Math.max(1, Runtime.getRuntime().availableProcessors());
|
||||||
this.pregenWorkerThreads = pregenWorkerThreadTarget(detectedCpuThreads, Runtime.getRuntime().maxMemory(),
|
this.pregenWorkerThreads = pregenWorkerThreadTarget(detectedCpuThreads, MantleHeapPressure.memoryBudgetBytes(),
|
||||||
!foliaRuntime && !strictSerial && detectedWorkerPoolThreads > 0);
|
!foliaRuntime && !strictSerial && detectedWorkerPoolThreads > 0);
|
||||||
int workerThreadsForCap = foliaRuntime
|
int workerThreadsForCap = foliaRuntime
|
||||||
? resolveFoliaConcurrencyWorkerThreads(detectedWorkerPoolThreads, detectedCpuThreads, pregenWorkerThreads)
|
? resolveFoliaConcurrencyWorkerThreads(detectedWorkerPoolThreads, detectedCpuThreads, pregenWorkerThreads)
|
||||||
@@ -166,7 +166,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
|||||||
int configuredThreads = foliaRuntime
|
int configuredThreads = foliaRuntime
|
||||||
? computeFoliaRecommendedCap(workerThreadsForCap)
|
? computeFoliaRecommendedCap(workerThreadsForCap)
|
||||||
: computePaperLikeRecommendedCap(workerThreadsForCap);
|
: computePaperLikeRecommendedCap(workerThreadsForCap);
|
||||||
configuredThreads = heapLimitedConcurrency(configuredThreads, Runtime.getRuntime().maxMemory());
|
configuredThreads = heapLimitedConcurrency(configuredThreads, MantleHeapPressure.memoryBudgetBytes());
|
||||||
this.threads = selectConcurrencyCap(configuredThreads, strictSerial);
|
this.threads = selectConcurrencyCap(configuredThreads, strictSerial);
|
||||||
this.workerPoolThreads = detectedWorkerPoolThreads;
|
this.workerPoolThreads = detectedWorkerPoolThreads;
|
||||||
this.runtimeCpuThreads = detectedCpuThreads;
|
this.runtimeCpuThreads = detectedCpuThreads;
|
||||||
@@ -304,7 +304,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
|||||||
binding.releaseChunk(world, chunkX, chunkZ);
|
binding.releaseChunk(world, chunkX, chunkZ);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return new PregenChunkRetention(tickets, radius, retentionCapacity(Runtime.getRuntime().maxMemory()),
|
return new PregenChunkRetention(tickets, radius, retentionCapacity(MantleHeapPressure.memoryBudgetBytes()),
|
||||||
minRegionX, minRegionZ, maxRegionX, maxRegionZ);
|
minRegionX, minRegionZ, maxRegionX, maxRegionZ);
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
PregenDiagnostics.probeFailed("pregen chunk retention for world " + world.getName(), e);
|
PregenDiagnostics.probeFailed("pregen chunk retention for world " + world.getName(), e);
|
||||||
@@ -996,7 +996,14 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
|||||||
|
|
||||||
PregenAdmissionGate.Wait waited;
|
PregenAdmissionGate.Wait waited;
|
||||||
try {
|
try {
|
||||||
|
while (true) {
|
||||||
waited = admission.admit(() -> inFlight.get() >= adaptiveInFlightLimit.get(), this::isCancelled);
|
waited = admission.admit(() -> inFlight.get() >= adaptiveInFlightLimit.get(), this::isCancelled);
|
||||||
|
if (waited == null || !MantleHeapPressure.overHighWater()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
admission.release();
|
||||||
|
backpressure.awaitHeapHeadroom(MantleHeapPressure::overHighWater, this::reclaimHeapPressure);
|
||||||
|
}
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ public class IrisPregenerator {
|
|||||||
try {
|
try {
|
||||||
generator.reclaimMemory();
|
generator.reclaimMemory();
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
IrisLogging.reportError("Pregen could not reclaim completed server chunks under heap pressure.", e);
|
IrisLogging.reportError("Pregen could not reclaim completed server chunks under memory pressure.", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
package art.arcane.iris.world.pregen;
|
package art.arcane.iris.world.pregen;
|
||||||
|
|
||||||
import art.arcane.iris.spi.IrisLogging;
|
import art.arcane.iris.spi.IrisLogging;
|
||||||
|
import art.arcane.volmlib.util.diagnostics.ContainerMemory;
|
||||||
|
|
||||||
import javax.management.MBeanServer;
|
import javax.management.MBeanServer;
|
||||||
import javax.management.ObjectName;
|
import javax.management.ObjectName;
|
||||||
@@ -31,6 +32,8 @@ import java.util.function.LongSupplier;
|
|||||||
public final class MantleHeapPressure {
|
public final class MantleHeapPressure {
|
||||||
private static final double HIGH_WATER = 0.92D;
|
private static final double HIGH_WATER = 0.92D;
|
||||||
private static final double LOW_WATER = 0.82D;
|
private static final double LOW_WATER = 0.82D;
|
||||||
|
private static final double CONTAINER_HIGH_WATER = 0.90D;
|
||||||
|
private static final double CONTAINER_LOW_WATER = 0.80D;
|
||||||
private static final long MAXIMUM_HYSTERESIS_MS = 60_000L;
|
private static final long MAXIMUM_HYSTERESIS_MS = 60_000L;
|
||||||
private static final ObjectName DIAGNOSTIC_COMMAND = diagnosticCommandName();
|
private static final ObjectName DIAGNOSTIC_COMMAND = diagnosticCommandName();
|
||||||
private static final PanicGcReclaimer PANIC_RECLAIMER = new PanicGcReclaimer(
|
private static final PanicGcReclaimer PANIC_RECLAIMER = new PanicGcReclaimer(
|
||||||
@@ -42,7 +45,7 @@ public final class MantleHeapPressure {
|
|||||||
// An escalation step in the reclaim policy, rate limited by that policy rather than by
|
// An escalation step in the reclaim policy, rate limited by that policy rather than by
|
||||||
// anything the operator did. What is actionable is the wait budget being exceeded.
|
// anything the operator did. What is actionable is the wait budget being exceeded.
|
||||||
(double fraction) -> IrisLogging.info(
|
(double fraction) -> IrisLogging.info(
|
||||||
"Iris heap remained at %.1f%% after normal panic reclaim; invoking the current JVM's diagnostic full GC to keep generation live.",
|
"Iris memory pressure remained at %.1f%% of its safety budget after normal panic reclaim; invoking the current JVM's diagnostic full GC to keep generation live.",
|
||||||
fraction * 100.0D),
|
fraction * 100.0D),
|
||||||
(String context, Throwable failure) -> IrisLogging.reportError(context, failure)));
|
(String context, Throwable failure) -> IrisLogging.reportError(context, failure)));
|
||||||
private static final HeapPressureGate PRESSURE_GATE = new HeapPressureGate(
|
private static final HeapPressureGate PRESSURE_GATE = new HeapPressureGate(
|
||||||
@@ -51,11 +54,47 @@ public final class MantleHeapPressure {
|
|||||||
MAXIMUM_HYSTERESIS_MS,
|
MAXIMUM_HYSTERESIS_MS,
|
||||||
System::currentTimeMillis,
|
System::currentTimeMillis,
|
||||||
PANIC_RECLAIMER::resetEpisode);
|
PANIC_RECLAIMER::resetEpisode);
|
||||||
|
private static final HeapPressureGate CONTAINER_PRESSURE_GATE = new HeapPressureGate(
|
||||||
|
CONTAINER_HIGH_WATER,
|
||||||
|
CONTAINER_LOW_WATER,
|
||||||
|
Long.MAX_VALUE,
|
||||||
|
System::currentTimeMillis,
|
||||||
|
PANIC_RECLAIMER::resetEpisode);
|
||||||
|
|
||||||
private MantleHeapPressure() {
|
private MantleHeapPressure() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static double usedFraction() {
|
public static double usedFraction() {
|
||||||
|
return pressureFraction(heapUsedFraction(), ContainerMemory.snapshot().pressureFraction());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long memoryBudgetBytes() {
|
||||||
|
return memoryBudgetBytes(Runtime.getRuntime().maxMemory(), ContainerMemory.snapshot().limitBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
static long memoryBudgetBytes(long heapBytes, long containerBytes) {
|
||||||
|
return Math.min(heapBytes, containerBytes - containerBytes / 4L);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double pressureFraction(double heapFraction, double containerFraction) {
|
||||||
|
if (containerFraction >= CONTAINER_HIGH_WATER) {
|
||||||
|
return Math.max(heapFraction, HIGH_WATER + (containerFraction - CONTAINER_HIGH_WATER));
|
||||||
|
}
|
||||||
|
double containerPressure = containerFraction <= 0D ? 0D
|
||||||
|
: LOW_WATER + (containerFraction - CONTAINER_LOW_WATER)
|
||||||
|
* (HIGH_WATER - LOW_WATER) / (CONTAINER_HIGH_WATER - CONTAINER_LOW_WATER);
|
||||||
|
return Math.max(heapFraction, containerPressure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String describe() {
|
||||||
|
ContainerMemory.Snapshot container = ContainerMemory.snapshot();
|
||||||
|
String heap = Math.round(heapUsedFraction() * 100D) + "% heap";
|
||||||
|
return container.supported()
|
||||||
|
? heap + ", " + Math.round(container.pressureFraction() * 100D) + "% container working memory"
|
||||||
|
: heap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double heapUsedFraction() {
|
||||||
MemoryUsage heap = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();
|
MemoryUsage heap = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();
|
||||||
long max = heap.getMax();
|
long max = heap.getMax();
|
||||||
if (max <= 0L) {
|
if (max <= 0L) {
|
||||||
@@ -71,7 +110,9 @@ public final class MantleHeapPressure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static boolean overHighWater() {
|
public static boolean overHighWater() {
|
||||||
return PRESSURE_GATE.update(usedFraction());
|
boolean heapPressure = PRESSURE_GATE.update(heapUsedFraction());
|
||||||
|
boolean containerPressure = CONTAINER_PRESSURE_GATE.update(ContainerMemory.snapshot().pressureFraction());
|
||||||
|
return heapPressure || containerPressure;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static double reclaimUrgency(double fraction) {
|
public static double reclaimUrgency(double fraction) {
|
||||||
@@ -86,7 +127,7 @@ public final class MantleHeapPressure {
|
|||||||
|
|
||||||
public static void requestPanicReclaim() {
|
public static void requestPanicReclaim() {
|
||||||
double fraction = usedFraction();
|
double fraction = usedFraction();
|
||||||
PANIC_RECLAIMER.request(fraction, PRESSURE_GATE.update(fraction));
|
PANIC_RECLAIMER.request(fraction, overHighWater());
|
||||||
}
|
}
|
||||||
|
|
||||||
static void invokeHotSpotDiagnosticGc(MBeanServer server) throws Exception {
|
static void invokeHotSpotDiagnosticGc(MBeanServer server) throws Exception {
|
||||||
|
|||||||
@@ -215,16 +215,16 @@ public final class PregenMantleBackpressure {
|
|||||||
if (elapsed >= nextWarningElapsed) {
|
if (elapsed >= nextWarningElapsed) {
|
||||||
nextWarningElapsed = elapsed + warningInterval;
|
nextWarningElapsed = elapsed + warningInterval;
|
||||||
lastLog = logNow;
|
lastLog = logNow;
|
||||||
IrisLogging.warn("Pregen heap pressure is still blocking new chunk submissions after " + elapsed + "ms at "
|
IrisLogging.warn("Pregen memory pressure is still blocking new chunk submissions after " + elapsed + "ms at "
|
||||||
+ Math.round(MantleHeapPressure.usedFraction() * 100.0D) + "% heap; waiting for headroom. "
|
+ MantleHeapPressure.describe() + "; waiting for headroom. "
|
||||||
+ diagnostics.get());
|
+ diagnostics.get());
|
||||||
onBudgetTimeout.run();
|
onBudgetTimeout.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (logNow - lastLog >= LOG_INTERVAL_MS) {
|
if (logNow - lastLog >= LOG_INTERVAL_MS) {
|
||||||
lastLog = logNow;
|
lastLog = logNow;
|
||||||
IrisLogging.info("Pregen heap pressure: pausing generation at "
|
IrisLogging.info("Pregen memory pressure: pausing generation at "
|
||||||
+ Math.round(MantleHeapPressure.usedFraction() * 100.0D) + "% heap; evicting tectonic plates and waiting for headroom"
|
+ MantleHeapPressure.describe() + "; evicting tectonic plates and waiting for headroom"
|
||||||
+ (mantle != null ? " (" + mantle.getLoadedRegionCount() + " plates resident)" : "") + ".");
|
+ (mantle != null ? " (" + mantle.getLoadedRegionCount() + " plates resident)" : "") + ".");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -289,6 +289,9 @@ public class J {
|
|||||||
runnable.run();
|
runnable.run();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (!canSchedule()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (runRegionImmediate(world, chunkX, chunkZ, runnable)) {
|
if (runRegionImmediate(world, chunkX, chunkZ, runnable)) {
|
||||||
return true;
|
return true;
|
||||||
@@ -383,6 +386,9 @@ public class J {
|
|||||||
if (delayTicks <= 0) {
|
if (delayTicks <= 0) {
|
||||||
return runRegion(world, chunkX, chunkZ, runnable);
|
return runRegion(world, chunkX, chunkZ, runnable);
|
||||||
}
|
}
|
||||||
|
if (!canSchedule()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (runRegionDelayed(world, chunkX, chunkZ, runnable, delayTicks)) {
|
if (runRegionDelayed(world, chunkX, chunkZ, runnable, delayTicks)) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -71,6 +71,19 @@ public class IrisSettingsDefaultsTest {
|
|||||||
assertEquals(8_192, settings.getGenerationTransitionWidthBlocks());
|
assertEquals(8_192, settings.getGenerationTransitionWidthBlocks());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void studioBlendingDefaultsOnAndAcceptsDisabledHotload() {
|
||||||
|
IrisSettings defaults = new IrisSettings();
|
||||||
|
assertTrue(defaults.getStudio().isBlendChanges());
|
||||||
|
assertTrue(new Gson().fromJson("{\"studio\":{}}", IrisSettings.class).getStudio().isBlendChanges());
|
||||||
|
assertTrue(new Gson().toJson(defaults).contains("\"blendChanges\":true"));
|
||||||
|
|
||||||
|
IrisSettings changed = IrisSettings.parseHotloadSnapshot("{\"studio\":{\"blendChanges\":false}}");
|
||||||
|
|
||||||
|
assertFalse(changed.getStudio().isBlendChanges());
|
||||||
|
assertEquals(256, changed.getGenerator().getGenerationTransitionWidthBlocks());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void hotloadSnapshotIsValidatedBeforeReplacingLiveSettings() {
|
public void hotloadSnapshotIsValidatedBeforeReplacingLiveSettings() {
|
||||||
IrisSettings previous = IrisSettings.settings;
|
IrisSettings previous = IrisSettings.settings;
|
||||||
|
|||||||
+46
@@ -5,9 +5,12 @@ import art.arcane.iris.generation.runtime.IrisComplex;
|
|||||||
import art.arcane.iris.generation.mantle.EngineMantle;
|
import art.arcane.iris.generation.mantle.EngineMantle;
|
||||||
import art.arcane.iris.generation.runtime.Engine;
|
import art.arcane.iris.generation.runtime.Engine;
|
||||||
import art.arcane.iris.generation.runtime.SeedManager;
|
import art.arcane.iris.generation.runtime.SeedManager;
|
||||||
|
import art.arcane.iris.generation.context.ChunkContext;
|
||||||
|
import art.arcane.iris.generation.context.IrisContext;
|
||||||
import art.arcane.iris.generation.terrain.InferredType;
|
import art.arcane.iris.generation.terrain.InferredType;
|
||||||
import art.arcane.iris.generation.biome.IrisBiome;
|
import art.arcane.iris.generation.biome.IrisBiome;
|
||||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||||
|
import art.arcane.iris.generation.terrain.Terrain3DColumn;
|
||||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||||
import art.arcane.iris.spi.IrisPlatform;
|
import art.arcane.iris.spi.IrisPlatform;
|
||||||
import art.arcane.iris.spi.IrisPlatforms;
|
import art.arcane.iris.spi.IrisPlatforms;
|
||||||
@@ -29,6 +32,7 @@ import static org.mockito.ArgumentMatchers.anyDouble;
|
|||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
import static org.mockito.Mockito.doReturn;
|
import static org.mockito.Mockito.doReturn;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
public class IrisDecoratorCaveContextTest {
|
public class IrisDecoratorCaveContextTest {
|
||||||
@ClassRule
|
@ClassRule
|
||||||
@@ -105,6 +109,48 @@ public class IrisDecoratorCaveContextTest {
|
|||||||
assertFalse(IrisSurfaceDecorator.skipsFluid(null));
|
assertFalse(IrisSurfaceDecorator.skipsFluid(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void dryBlendedSurfaceRunsBiomeDecoratorDespiteHigherModeledWater() {
|
||||||
|
Engine engine = mock(Engine.class);
|
||||||
|
doReturn(mock(SeedManager.class)).when(engine).getSeedManager();
|
||||||
|
doReturn(mock(IrisData.class)).when(engine).getData();
|
||||||
|
doReturn(7L).when(engine).getGenerationSessionId();
|
||||||
|
IrisDimension dimension = mock(IrisDimension.class);
|
||||||
|
doReturn(dimension).when(engine).getDimension();
|
||||||
|
EngineMantle mantle = mock(EngineMantle.class);
|
||||||
|
doReturn(mantle).when(engine).getMantle();
|
||||||
|
doReturn(63).when(mantle).getFluidHeight(anyInt(), anyInt());
|
||||||
|
|
||||||
|
IrisDecorator decorator = mock(IrisDecorator.class);
|
||||||
|
doReturn(true).when(decorator).passesChanceGate(any(), anyDouble(), anyDouble(), any());
|
||||||
|
doReturn(true).when(decorator).isStacking();
|
||||||
|
doReturn(true).when(decorator).isForcePlace();
|
||||||
|
doReturn(1).when(decorator).getHeight(any(), anyDouble(), anyDouble(), any());
|
||||||
|
IrisBiome biome = mock(IrisBiome.class);
|
||||||
|
doReturn(InferredType.LAND).when(biome).getInferredType();
|
||||||
|
doReturn(new IrisDecorator[]{decorator}).when(biome).getDecoratorBucket(IrisDecorationPart.NONE);
|
||||||
|
ChunkContext chunk = mock(ChunkContext.class);
|
||||||
|
doReturn(7L).when(chunk).getGenerationSessionId();
|
||||||
|
doReturn(new ChunkContext.TransitionTerrainColumn(Terrain3DColumn.unshaped(40, 128), -1))
|
||||||
|
.when(chunk).getTransitionTerrain(0, 0);
|
||||||
|
Hunk<NativeBlockState> output = Hunk.newArrayHunk(16, 128, 16);
|
||||||
|
output.fill(IrisPlatforms.get().registries().air());
|
||||||
|
IrisSurfaceDecorator surface = new IrisSurfaceDecorator(engine);
|
||||||
|
|
||||||
|
try (IrisContext.Scope scope = IrisContext.open(engine, 7L, chunk)) {
|
||||||
|
surface.decorate(0, 0, 0, 0, 0, 0, 0, 0, output, biome, 40, 88);
|
||||||
|
assertEquals(-1, surface.surfaceFluidHeight(0, 0));
|
||||||
|
assertEquals(63, surface.surfaceFluidHeight(16, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(decorator).passesChanceGate(any(), anyDouble(), anyDouble(), any());
|
||||||
|
assertFalse(DecoratorCore.SCRATCH_OPTS.get().underwater);
|
||||||
|
assertEquals(63, surface.surfaceFluidHeight(0, 0));
|
||||||
|
try (IrisContext.Scope scope = IrisContext.open(engine, 8L, chunk)) {
|
||||||
|
assertEquals(63, surface.surfaceFluidHeight(0, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public void underwaterSurfaceDecorationRequiresActualFluidAboveTheTerrain() {
|
public void underwaterSurfaceDecorationRequiresActualFluidAboveTheTerrain() {
|
||||||
|
|||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
package art.arcane.iris.generation.runtime;
|
||||||
|
|
||||||
|
import art.arcane.iris.configuration.IrisSettings;
|
||||||
|
import art.arcane.iris.platform.bukkit.nms.BukkitGenerationPolicy;
|
||||||
|
import art.arcane.iris.world.IrisWorld;
|
||||||
|
import art.arcane.iris.world.history.GenerationActivation;
|
||||||
|
import art.arcane.iris.world.history.GenerationHistory;
|
||||||
|
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
|
||||||
|
import art.arcane.iris.world.history.GenerationManifest;
|
||||||
|
import art.arcane.iris.world.history.IrisBoundarySignatureSampler;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertNotEquals;
|
||||||
|
import static org.junit.Assert.assertSame;
|
||||||
|
import static org.junit.Assert.assertThrows;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
public class EngineHotloaderRecoveryTest {
|
||||||
|
@Test
|
||||||
|
public void failedBoundaryCaptureKeepsNativeGenerationAvailableWithPendingUpdate() throws Exception {
|
||||||
|
RecoveryFixture fixture = new RecoveryFixture(1L);
|
||||||
|
long previousSession = fixture.sessions.currentSessionId();
|
||||||
|
|
||||||
|
fixture.failPromotion();
|
||||||
|
|
||||||
|
assertEquals(IrisEngine.LifecycleState.RUNNING, fixture.engine.lifecycleState);
|
||||||
|
assertSame(fixture.previousRuntime, fixture.engine.runtime);
|
||||||
|
assertFalse(fixture.closing.get());
|
||||||
|
try (GenerationSessionLease lease = new BukkitGenerationPolicy(fixture.engine, null)
|
||||||
|
.acquireLease("bukkit_nms_build_surface")) {
|
||||||
|
assertNotEquals(previousSession, lease.sessionId());
|
||||||
|
assertEquals(1, fixture.sessions.activeLeases());
|
||||||
|
}
|
||||||
|
verify(fixture.background).openBackgroundTaskAdmission();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void failureAfterDurableActivationDoesNotReopenPreviousGeneration() throws Exception {
|
||||||
|
RecoveryFixture fixture = new RecoveryFixture(2L);
|
||||||
|
|
||||||
|
fixture.failPromotion();
|
||||||
|
|
||||||
|
assertEquals(IrisEngine.LifecycleState.FAILED, fixture.engine.lifecycleState);
|
||||||
|
assertTrue(fixture.closing.get());
|
||||||
|
assertThrows(IllegalStateException.class, () -> new BukkitGenerationPolicy(fixture.engine, null)
|
||||||
|
.acquireLease("bukkit_nms_build_surface"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void unreadableHistoryPreservesFailureAndKeepsGenerationSealed() throws Exception {
|
||||||
|
RecoveryFixture fixture = new RecoveryFixture(1L);
|
||||||
|
IllegalStateException historyFailure = new IllegalStateException("Manifest unavailable");
|
||||||
|
when(fixture.history.manifest()).thenReturn(fixture.previousManifest).thenThrow(historyFailure);
|
||||||
|
|
||||||
|
fixture.failPromotion();
|
||||||
|
|
||||||
|
assertEquals(IrisEngine.LifecycleState.FAILED, fixture.engine.lifecycleState);
|
||||||
|
assertTrue(fixture.closing.get());
|
||||||
|
assertSame(historyFailure, fixture.failure.getSuppressed()[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setField(IrisEngine engine, String name, Object value) throws ReflectiveOperationException {
|
||||||
|
Field field = IrisEngine.class.getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(engine, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class RecoveryFixture {
|
||||||
|
private final IrisEngine engine = mock(IrisEngine.class);
|
||||||
|
private final EngineRuntime previousRuntime = mock(EngineRuntime.class);
|
||||||
|
private final GenerationSessionManager sessions = new GenerationSessionManager(true);
|
||||||
|
private final AtomicBoolean closing = new AtomicBoolean();
|
||||||
|
private final EngineBackgroundTasks background = mock(EngineBackgroundTasks.class);
|
||||||
|
private final GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||||
|
private final GenerationHistory history = mock(GenerationHistory.class);
|
||||||
|
private final GenerationManifest previousManifest = mock(GenerationManifest.class);
|
||||||
|
private final GenerationHistoryRuntimeRouter.StudioCutover cutover = mock(
|
||||||
|
GenerationHistoryRuntimeRouter.StudioCutover.class);
|
||||||
|
private final StudioGenerationUpdate update = mock(StudioGenerationUpdate.class);
|
||||||
|
private final IOException failure = new IOException("Unable to capture terrain boundary");
|
||||||
|
|
||||||
|
private RecoveryFixture(long currentActivationId) throws Exception {
|
||||||
|
setField(engine, "lifecycleLock", new Object());
|
||||||
|
setField(engine, "backgroundTasks", background);
|
||||||
|
setField(engine, "runtimeBuilder", new EngineRuntimeBuilder(engine));
|
||||||
|
engine.runtime = previousRuntime;
|
||||||
|
engine.lifecycleState = IrisEngine.LifecycleState.RUNNING;
|
||||||
|
when(engine.isStudio()).thenReturn(true);
|
||||||
|
when(engine.isClosing()).thenAnswer(invocation -> closing.get());
|
||||||
|
when(engine.getClosing()).thenReturn(closing);
|
||||||
|
when(engine.getGenerationSessions()).thenReturn(sessions);
|
||||||
|
when(engine.acquireGenerationLease(anyString())).thenAnswer(invocation ->
|
||||||
|
sessions.acquireForEngine(engine, invocation.getArgument(0, String.class)));
|
||||||
|
when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router));
|
||||||
|
when(router.history()).thenReturn(history);
|
||||||
|
when(router.beginStudioCutover(anyLong())).thenReturn(cutover);
|
||||||
|
when(background.drainBackgroundTasks(anyString())).thenReturn(
|
||||||
|
new EngineBackgroundTasks.BackgroundTaskDrain(null, true));
|
||||||
|
IrisWorld world = mock(IrisWorld.class);
|
||||||
|
when(world.name()).thenReturn("studio-recovery");
|
||||||
|
when(engine.getWorld()).thenReturn(world);
|
||||||
|
GenerationActivation previousActivation = mock(GenerationActivation.class);
|
||||||
|
when(previousActivation.activationId()).thenReturn(1L);
|
||||||
|
when(previousManifest.activeActivation()).thenReturn(previousActivation);
|
||||||
|
GenerationManifest stagedManifest = mock(GenerationManifest.class);
|
||||||
|
GenerationActivation currentActivation = mock(GenerationActivation.class);
|
||||||
|
when(currentActivation.activationId()).thenReturn(currentActivationId);
|
||||||
|
when(stagedManifest.activeActivation()).thenReturn(currentActivation);
|
||||||
|
when(history.manifest()).thenReturn(previousManifest, stagedManifest);
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
closing.set(true);
|
||||||
|
sessions.sealAndAwait(invocation.getArgument(0, String.class), 1000L);
|
||||||
|
return null;
|
||||||
|
}).when(engine).sealForTransition(anyString(), anyBoolean());
|
||||||
|
doThrow(failure).when(cutover).promotePending();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void failPromotion() throws Exception {
|
||||||
|
IrisSettings settings = mock(IrisSettings.class, RETURNS_DEEP_STUBS);
|
||||||
|
try (MockedStatic<StudioGenerationUpdate> updates = mockStatic(StudioGenerationUpdate.class);
|
||||||
|
MockedStatic<IrisBoundarySignatureSampler> signatures = mockStatic(IrisBoundarySignatureSampler.class);
|
||||||
|
MockedStatic<IrisSettings> configuration = mockStatic(IrisSettings.class)) {
|
||||||
|
updates.when(() -> StudioGenerationUpdate.prepare(engine, history)).thenReturn(update);
|
||||||
|
configuration.when(IrisSettings::get).thenReturn(settings);
|
||||||
|
|
||||||
|
IllegalStateException thrown = assertThrows(IllegalStateException.class,
|
||||||
|
() -> new EngineHotloader(engine).hotloadSilently());
|
||||||
|
assertSame(failure, thrown.getCause());
|
||||||
|
}
|
||||||
|
verify(cutover).close();
|
||||||
|
verify(update).close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
package art.arcane.iris.generation.runtime;
|
||||||
|
|
||||||
|
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||||
|
import art.arcane.iris.configuration.IrisSettings;
|
||||||
|
import art.arcane.iris.structure.placement.IrisStructureLocator;
|
||||||
|
import art.arcane.iris.structure.placement.StructureReachability;
|
||||||
|
import art.arcane.iris.world.IrisWorld;
|
||||||
|
import art.arcane.iris.world.history.GenerationHistory;
|
||||||
|
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
|
||||||
|
import art.arcane.iris.world.history.IrisBoundarySignatureSampler;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
public class EngineHotloaderRetirementTest {
|
||||||
|
@Test
|
||||||
|
public void studioPromotionDrainsHydrologyWorkersOutsideLifecycleLock() throws Exception {
|
||||||
|
IrisEngine engine = mock(IrisEngine.class);
|
||||||
|
Object lifecycleLock = new Object();
|
||||||
|
setField(engine, "lifecycleLock", lifecycleLock);
|
||||||
|
EngineBackgroundTasks background = mock(EngineBackgroundTasks.class);
|
||||||
|
when(background.drainBackgroundTasks(anyString())).thenReturn(
|
||||||
|
new EngineBackgroundTasks.BackgroundTaskDrain(null, true));
|
||||||
|
setField(engine, "backgroundTasks", background);
|
||||||
|
EngineRuntimeBuilder runtimeBuilder = mock(EngineRuntimeBuilder.class);
|
||||||
|
setField(engine, "runtimeBuilder", runtimeBuilder);
|
||||||
|
engine.runtime = mock(EngineRuntime.class);
|
||||||
|
engine.lifecycleState = IrisEngine.LifecycleState.RUNNING;
|
||||||
|
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||||
|
GenerationHistory history = mock(GenerationHistory.class);
|
||||||
|
when(router.history()).thenReturn(history);
|
||||||
|
when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router));
|
||||||
|
when(engine.isStudio()).thenReturn(true);
|
||||||
|
IrisWorld world = mock(IrisWorld.class);
|
||||||
|
when(world.name()).thenReturn("studio");
|
||||||
|
when(engine.getWorld()).thenReturn(world);
|
||||||
|
when(engine.getDimension()).thenReturn(mock(IrisDimension.class));
|
||||||
|
when(engine.getEngineData()).thenReturn(mock(IrisEngineData.class, RETURNS_DEEP_STUBS));
|
||||||
|
when(engine.getPlatformHooks()).thenReturn(mock(EnginePlatformHooks.class));
|
||||||
|
when(engine.getPrefetchSaveStarted()).thenReturn(new AtomicBoolean());
|
||||||
|
GenerationHistoryRuntimeRouter.StudioCutover cutover = mock(
|
||||||
|
GenerationHistoryRuntimeRouter.StudioCutover.class);
|
||||||
|
when(router.beginStudioCutover(anyLong())).thenReturn(cutover);
|
||||||
|
AtomicBoolean drained = new AtomicBoolean();
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
assertFalse(Thread.holdsLock(lifecycleLock));
|
||||||
|
CompletableFuture<Void> planning = CompletableFuture.runAsync(() -> {
|
||||||
|
synchronized (lifecycleLock) {
|
||||||
|
drained.set(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
planning.get(5L, TimeUnit.SECONDS);
|
||||||
|
return null;
|
||||||
|
}).when(cutover).promotePending();
|
||||||
|
StudioGenerationUpdate update = mock(StudioGenerationUpdate.class);
|
||||||
|
IrisSettings settings = mock(IrisSettings.class, RETURNS_DEEP_STUBS);
|
||||||
|
|
||||||
|
try (MockedStatic<StudioGenerationUpdate> updates = mockStatic(StudioGenerationUpdate.class);
|
||||||
|
MockedStatic<IrisBoundarySignatureSampler> signatures = mockStatic(IrisBoundarySignatureSampler.class);
|
||||||
|
MockedStatic<IrisStructureLocator> structures = mockStatic(IrisStructureLocator.class);
|
||||||
|
MockedStatic<StructureReachability> reachability = mockStatic(StructureReachability.class);
|
||||||
|
MockedStatic<IrisSettings> configuration = mockStatic(IrisSettings.class)) {
|
||||||
|
updates.when(() -> StudioGenerationUpdate.prepare(engine, history)).thenReturn(update);
|
||||||
|
configuration.when(IrisSettings::get).thenReturn(settings);
|
||||||
|
|
||||||
|
new EngineHotloader(engine).hotloadSilently();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(drained.get());
|
||||||
|
verify(runtimeBuilder).refreshStudioRuntimeServices();
|
||||||
|
verify(cutover).close();
|
||||||
|
verify(update).close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setField(IrisEngine engine, String name, Object value) throws ReflectiveOperationException {
|
||||||
|
Field field = IrisEngine.class.getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(engine, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package art.arcane.iris.generation.stage;
|
||||||
|
|
||||||
|
import art.arcane.iris.generation.context.ChunkContext;
|
||||||
|
import art.arcane.iris.generation.terrain.Terrain3DColumn;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public class IrisBlendedDecorationTest {
|
||||||
|
@Test
|
||||||
|
public void shiftedNaturalOpeningUsesRemainingBlendedFloorAndCeiling() {
|
||||||
|
Terrain3DColumn natural = column(5, 15);
|
||||||
|
ChunkContext.TransitionTerrainColumn transition = transition(column(3, 12));
|
||||||
|
|
||||||
|
assertEquals(new IrisDecorantActuator.TerrainLedge(3, 12),
|
||||||
|
IrisDecorantActuator.resolveTerrainLedge(natural, 0, transition));
|
||||||
|
assertTrue(IrisDecorantActuator.isTransitionTerrainOpening(natural, transition, 4));
|
||||||
|
assertFalse(IrisDecorantActuator.isTransitionTerrainOpening(natural, transition, 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void splitOpeningDecoratesEachSupportedBlendedLedge() {
|
||||||
|
Terrain3DColumn natural = column(3, 20);
|
||||||
|
Terrain3DColumn blended = Terrain3DColumn.fromOccupancy(31, 32,
|
||||||
|
y -> y <= 5 || y >= 10 && y <= 12 || y >= 18);
|
||||||
|
ChunkContext.TransitionTerrainColumn transition = transition(blended);
|
||||||
|
|
||||||
|
assertEquals(new IrisDecorantActuator.TerrainLedge(5, 10),
|
||||||
|
IrisDecorantActuator.resolveTerrainLedge(natural, 0, transition));
|
||||||
|
assertEquals(new IrisDecorantActuator.TerrainLedge(12, 18),
|
||||||
|
IrisDecorantActuator.resolveTerrainLedge(natural, 1, transition));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void unrelatedCaveOpeningRetainsCaveDecorationOwnership() {
|
||||||
|
Terrain3DColumn natural = column(18, 25);
|
||||||
|
ChunkContext.TransitionTerrainColumn transition = transition(column(3, 12));
|
||||||
|
|
||||||
|
assertNull(IrisDecorantActuator.resolveTerrainLedge(natural, 0, transition));
|
||||||
|
assertFalse(IrisDecorantActuator.isTransitionTerrainOpening(natural, transition, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void filledOpeningDoesNotRetainTerrainDecorationTargets() {
|
||||||
|
Terrain3DColumn natural = column(3, 12);
|
||||||
|
ChunkContext.TransitionTerrainColumn transition = transition(
|
||||||
|
Terrain3DColumn.fromOccupancy(31, 32, y -> true));
|
||||||
|
|
||||||
|
assertFalse(IrisDecorantActuator.isTransitionTerrainOpening(natural, transition, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Terrain3DColumn column(int floorY, int ceilingY) {
|
||||||
|
return Terrain3DColumn.fromOccupancy(31, 32, y -> y <= floorY || y >= ceilingY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChunkContext.TransitionTerrainColumn transition(Terrain3DColumn solids) {
|
||||||
|
return new ChunkContext.TransitionTerrainColumn(solids, solids.topY());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -246,6 +246,37 @@ public class IrisNaturalCaveScanTest {
|
|||||||
assertTrue(fixture.markers.isEmpty());
|
assertTrue(fixture.markers.isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void blendedShiftedNaturalOpeningRetainsSurfaceDecorationOwnership() throws Exception {
|
||||||
|
Fixture fixture = new Fixture();
|
||||||
|
Terrain3DColumn natural = Terrain3DColumn.fromOccupancy(31, 32, y -> y <= 5 || y >= 12);
|
||||||
|
Terrain3DColumn blended = Terrain3DColumn.fromOccupancy(31, 32, y -> y < 4 || y > 8);
|
||||||
|
doReturn(natural).when(fixture.complex).terrainColumn(anyInt(), anyInt(), any());
|
||||||
|
ChunkContext context = mock(ChunkContext.class);
|
||||||
|
doReturn(new ChunkContext.TransitionTerrainColumn(blended, 31))
|
||||||
|
.when(context).getTransitionTerrain(0, 0);
|
||||||
|
|
||||||
|
fixture.modifier.decorateNaturalCaves(-32, 48, fixture.cavity(), context);
|
||||||
|
|
||||||
|
assertTrue(fixture.calls.isEmpty());
|
||||||
|
assertTrue(fixture.markers.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void blendedUnrelatedCaveRemainsDecorated() throws Exception {
|
||||||
|
Fixture fixture = new Fixture();
|
||||||
|
Terrain3DColumn natural = Terrain3DColumn.fromOccupancy(31, 32, y -> y <= 18 || y >= 25);
|
||||||
|
Terrain3DColumn blended = Terrain3DColumn.fromOccupancy(31, 32, y -> y < 4 || y > 8);
|
||||||
|
doReturn(natural).when(fixture.complex).terrainColumn(anyInt(), anyInt(), any());
|
||||||
|
ChunkContext context = mock(ChunkContext.class);
|
||||||
|
doReturn(new ChunkContext.TransitionTerrainColumn(blended, 31))
|
||||||
|
.when(context).getTransitionTerrain(0, 0);
|
||||||
|
|
||||||
|
fixture.modifier.decorateNaturalCaves(-32, 48, fixture.cavity(), context);
|
||||||
|
|
||||||
|
assertEquals(List.of("floor:0:0:3:3", "ceiling:0:0:8:3"), fixture.calls);
|
||||||
|
}
|
||||||
|
|
||||||
private static void verifyScan(int width, int depth) throws Exception {
|
private static void verifyScan(int width, int depth) throws Exception {
|
||||||
Fixture fixture = new Fixture();
|
Fixture fixture = new Fixture();
|
||||||
Hunk<NativeBlockState> output = fixture.output(width, depth);
|
Hunk<NativeBlockState> output = fixture.output(width, depth);
|
||||||
|
|||||||
+33
-2
@@ -2,6 +2,7 @@ package art.arcane.iris.generation.stage;
|
|||||||
|
|
||||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
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.terrain.Terrain3DColumn;
|
||||||
import art.arcane.iris.world.history.BoundaryColumnGeometry;
|
import art.arcane.iris.world.history.BoundaryColumnGeometry;
|
||||||
import art.arcane.iris.world.history.BoundaryGeometryInfluence;
|
import art.arcane.iris.world.history.BoundaryGeometryInfluence;
|
||||||
import art.arcane.iris.world.history.NativeTerrainReceipt;
|
import art.arcane.iris.world.history.NativeTerrainReceipt;
|
||||||
@@ -106,6 +107,8 @@ public class IrisTransitionGeometryActuatorTest {
|
|||||||
assertSame(stone, blocks.getRaw(0, 3, 0));
|
assertSame(stone, blocks.getRaw(0, 3, 0));
|
||||||
assertSame(air, blocks.getRaw(0, 4, 0));
|
assertSame(air, blocks.getRaw(0, 4, 0));
|
||||||
verify(context).setTerrainHeight(0, 0, 3);
|
verify(context).setTerrainHeight(0, 0, 3);
|
||||||
|
verify(context).setTransitionTerrain(0, 0,
|
||||||
|
Terrain3DColumn.fromOccupancy(3, 16, y -> y == 0 || y == 3), -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -121,12 +124,40 @@ public class IrisTransitionGeometryActuatorTest {
|
|||||||
assertSame(air, blocks.getRaw(0, 2, 0));
|
assertSame(air, blocks.getRaw(0, 2, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void preservesTheActualBlendedSurfaceWaterHeight() {
|
||||||
|
NativeBlockState water = state("minecraft:water[level=0]", false);
|
||||||
|
when(water.isSolid()).thenReturn(false);
|
||||||
|
when(water.isFluid()).thenReturn(true);
|
||||||
|
when(registries.blockOrNull(water.key())).thenReturn(water);
|
||||||
|
ChunkContext context = context("minecraft:stone", 6);
|
||||||
|
Hunk<NativeBlockState> blocks = filled();
|
||||||
|
for (int y = 4; y <= 6; y++) {
|
||||||
|
blocks.setRaw(0, y, 0, water);
|
||||||
|
}
|
||||||
|
|
||||||
|
new IrisTransitionGeometryActuator(mock(Engine.class)).generate(16, 0, blocks,
|
||||||
|
Hunk.newArrayHunk(16, 16, 16), false, context);
|
||||||
|
|
||||||
|
assertSame(water, blocks.getRaw(0, 4, 0));
|
||||||
|
assertSame(water, blocks.getRaw(0, 6, 0));
|
||||||
|
assertSame(air, blocks.getRaw(0, 7, 0));
|
||||||
|
verify(context).setTransitionTerrain(0, 0,
|
||||||
|
Terrain3DColumn.fromOccupancy(3, 16, y -> y == 0 || y == 3), 6);
|
||||||
|
}
|
||||||
|
|
||||||
private ChunkContext context(String material) {
|
private ChunkContext context(String material) {
|
||||||
|
return context(material, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChunkContext context(String material, int fluidHeight) {
|
||||||
ArrayList<BoundaryColumnGeometry.Voxel> voxels = new ArrayList<>(16);
|
ArrayList<BoundaryColumnGeometry.Voxel> voxels = new ArrayList<>(16);
|
||||||
for (int y = 0; y < 16; y++) {
|
for (int y = 0; y < 16; y++) {
|
||||||
boolean solid = y == 0 || y == 3;
|
boolean solid = y == 0 || y == 3;
|
||||||
voxels.add(new BoundaryColumnGeometry.Voxel(solid ? material : "minecraft:air",
|
boolean fluid = y > 3 && y <= fluidHeight;
|
||||||
solid ? BoundaryColumnGeometry.Phase.SOLID : BoundaryColumnGeometry.Phase.AIR, "", false));
|
voxels.add(new BoundaryColumnGeometry.Voxel(solid ? material : fluid ? "minecraft:water[level=0]" : "minecraft:air",
|
||||||
|
solid ? BoundaryColumnGeometry.Phase.SOLID : fluid ? BoundaryColumnGeometry.Phase.FLUID : BoundaryColumnGeometry.Phase.AIR,
|
||||||
|
fluid ? "minecraft:water[level=0]" : "", false));
|
||||||
}
|
}
|
||||||
BoundaryGeometryInfluence influence = new BoundaryGeometryInfluence(0D, 0D,
|
BoundaryGeometryInfluence influence = new BoundaryGeometryInfluence(0D, 0D,
|
||||||
List.of(new BoundaryGeometryInfluence.Contribution(BoundaryColumnGeometry.fromVoxels(0, voxels), 1D)));
|
List.of(new BoundaryGeometryInfluence.Contribution(BoundaryColumnGeometry.fromVoxels(0, voxels), 1D)));
|
||||||
|
|||||||
+26
@@ -130,6 +130,32 @@ public class BukkitChunkGeneratorCloseTest {
|
|||||||
closed.join();
|
closed.join();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void studioQuiesceCancelsWatcherAndDrainsExclusiveControlWithoutClosingEngine() throws Exception {
|
||||||
|
CloseFixture fixture = new CloseFixture();
|
||||||
|
CompletableFuture<Void> pending = new CompletableFuture<>();
|
||||||
|
fixture.watcherHotload.set(pending);
|
||||||
|
Looper watcher = mock(Looper.class);
|
||||||
|
setField(fixture.generator, "hotloader", watcher);
|
||||||
|
CompletableFuture<Void> drained = fixture.queueOperation();
|
||||||
|
|
||||||
|
CompletableFuture<Void> quiesced = fixture.generator.quiesceStudioUpdatesAsync();
|
||||||
|
|
||||||
|
assertTrue(pending.isCancelled());
|
||||||
|
assertNull(fixture.watcherHotload.get());
|
||||||
|
assertFalse(quiesced.isDone());
|
||||||
|
assertFalse(fixture.generator.isClosing());
|
||||||
|
verify(watcher).interrupt();
|
||||||
|
fixture.generator.hotload();
|
||||||
|
verify(fixture.engine, times(0)).hotload();
|
||||||
|
verify(fixture.engine, times(0)).close();
|
||||||
|
|
||||||
|
fixture.operation.get().run();
|
||||||
|
drained.complete(null);
|
||||||
|
quiesced.join();
|
||||||
|
verify(fixture.engine, times(0)).close();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void shutdownQuiesceCancelsPendingWatcherWithoutClosingGeneration() throws Exception {
|
public void shutdownQuiesceCancelsPendingWatcherWithoutClosingGeneration() throws Exception {
|
||||||
CloseFixture fixture = new CloseFixture();
|
CloseFixture fixture = new CloseFixture();
|
||||||
|
|||||||
+57
-6
@@ -19,18 +19,65 @@ import static org.mockito.Mockito.mockStatic;
|
|||||||
|
|
||||||
public class StudioOpenCoordinatorCloseSequenceTest {
|
public class StudioOpenCoordinatorCloseSequenceTest {
|
||||||
@Test
|
@Test
|
||||||
public void unloadCompletesBeforeGeneratorCloseAndFolderDeletion() {
|
public void updatesDrainBeforeUnloadAndGeneratorClose() {
|
||||||
ArrayList<String> phases = new ArrayList<>();
|
ArrayList<String> phases = new ArrayList<>();
|
||||||
|
|
||||||
StudioOpenCoordinator.sequenceStudioClose(
|
StudioOpenCoordinator.sequenceStudioClose(
|
||||||
() -> phase(phases, "evacuate"),
|
() -> phase(phases, "evacuate"),
|
||||||
() -> phases.add("maintenance"),
|
() -> phases.add("maintenance"),
|
||||||
|
() -> phase(phases, "quiesce-updates"),
|
||||||
() -> phase(phases, "unload"),
|
() -> phase(phases, "unload"),
|
||||||
() -> phase(phases, "close-generator"),
|
() -> phase(phases, "close-generator"),
|
||||||
() -> phase(phases, "delete-folders")
|
() -> phase(phases, "delete-folders")
|
||||||
).join();
|
).join();
|
||||||
|
|
||||||
assertEquals(List.of("evacuate", "maintenance", "unload", "close-generator", "delete-folders"), phases);
|
assertEquals(List.of("evacuate", "maintenance", "quiesce-updates", "unload", "close-generator", "delete-folders"), phases);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void activeUpdateKeepsWorldLoadedUntilTerrainCaptureFinishes() {
|
||||||
|
ArrayList<String> phases = new ArrayList<>();
|
||||||
|
CompletableFuture<Void> update = new CompletableFuture<>();
|
||||||
|
CompletableFuture<Void> closed = StudioOpenCoordinator.sequenceStudioClose(
|
||||||
|
() -> phase(phases, "evacuate"),
|
||||||
|
() -> phases.add("maintenance"),
|
||||||
|
() -> {
|
||||||
|
phases.add("quiesce-updates");
|
||||||
|
return update;
|
||||||
|
},
|
||||||
|
() -> phase(phases, "unload"),
|
||||||
|
() -> phase(phases, "close-generator"),
|
||||||
|
() -> phase(phases, "delete-folders"));
|
||||||
|
|
||||||
|
assertFalse(closed.isDone());
|
||||||
|
assertEquals(List.of("evacuate", "maintenance", "quiesce-updates"), phases);
|
||||||
|
|
||||||
|
update.complete(null);
|
||||||
|
closed.join();
|
||||||
|
|
||||||
|
assertEquals(List.of("evacuate", "maintenance", "quiesce-updates", "unload",
|
||||||
|
"close-generator", "delete-folders"), phases);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void failedUpdateDrainKeepsWorldAndGeneratorAvailable() {
|
||||||
|
ArrayList<String> phases = new ArrayList<>();
|
||||||
|
IllegalStateException failure = new IllegalStateException("update remains active");
|
||||||
|
CompletableFuture<Void> closed = StudioOpenCoordinator.sequenceStudioClose(
|
||||||
|
() -> phase(phases, "evacuate"),
|
||||||
|
() -> phases.add("maintenance"),
|
||||||
|
() -> CompletableFuture.failedFuture(failure),
|
||||||
|
() -> phase(phases, "unload"),
|
||||||
|
() -> phase(phases, "close-generator"),
|
||||||
|
() -> phase(phases, "delete-folders"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
closed.join();
|
||||||
|
fail("Expected update drain failure");
|
||||||
|
} catch (CompletionException exception) {
|
||||||
|
assertSame(failure, exception.getCause());
|
||||||
|
}
|
||||||
|
assertEquals(List.of("evacuate", "maintenance"), phases);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -42,6 +89,7 @@ public class StudioOpenCoordinatorCloseSequenceTest {
|
|||||||
StudioOpenCoordinator.sequenceStudioClose(
|
StudioOpenCoordinator.sequenceStudioClose(
|
||||||
() -> phase(phases, "evacuate"),
|
() -> phase(phases, "evacuate"),
|
||||||
() -> phases.add("maintenance"),
|
() -> phases.add("maintenance"),
|
||||||
|
() -> phase(phases, "quiesce-updates"),
|
||||||
() -> {
|
() -> {
|
||||||
phases.add("unload");
|
phases.add("unload");
|
||||||
return CompletableFuture.failedFuture(failure);
|
return CompletableFuture.failedFuture(failure);
|
||||||
@@ -54,7 +102,7 @@ public class StudioOpenCoordinatorCloseSequenceTest {
|
|||||||
assertSame(failure, exception.getCause());
|
assertSame(failure, exception.getCause());
|
||||||
}
|
}
|
||||||
|
|
||||||
assertEquals(List.of("evacuate", "maintenance", "unload"), phases);
|
assertEquals(List.of("evacuate", "maintenance", "quiesce-updates", "unload"), phases);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -69,6 +117,7 @@ public class StudioOpenCoordinatorCloseSequenceTest {
|
|||||||
return CompletableFuture.failedFuture(failure);
|
return CompletableFuture.failedFuture(failure);
|
||||||
},
|
},
|
||||||
() -> phases.add("maintenance"),
|
() -> phases.add("maintenance"),
|
||||||
|
() -> phase(phases, "quiesce-updates"),
|
||||||
() -> phase(phases, "unload"),
|
() -> phase(phases, "unload"),
|
||||||
() -> phase(phases, "close-generator"),
|
() -> phase(phases, "close-generator"),
|
||||||
() -> phase(phases, "delete-folders")
|
() -> phase(phases, "delete-folders")
|
||||||
@@ -90,6 +139,7 @@ public class StudioOpenCoordinatorCloseSequenceTest {
|
|||||||
StudioOpenCoordinator.sequenceStudioClose(
|
StudioOpenCoordinator.sequenceStudioClose(
|
||||||
() -> phase(phases, "evacuate"),
|
() -> phase(phases, "evacuate"),
|
||||||
() -> phases.add("maintenance"),
|
() -> phases.add("maintenance"),
|
||||||
|
() -> phase(phases, "quiesce-updates"),
|
||||||
() -> phase(phases, "unload"),
|
() -> phase(phases, "unload"),
|
||||||
() -> {
|
() -> {
|
||||||
phases.add("close-generator");
|
phases.add("close-generator");
|
||||||
@@ -102,7 +152,7 @@ public class StudioOpenCoordinatorCloseSequenceTest {
|
|||||||
assertSame(failure, exception.getCause());
|
assertSame(failure, exception.getCause());
|
||||||
}
|
}
|
||||||
|
|
||||||
assertEquals(List.of("evacuate", "maintenance", "unload", "close-generator"), phases);
|
assertEquals(List.of("evacuate", "maintenance", "quiesce-updates", "unload", "close-generator"), phases);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -115,6 +165,7 @@ public class StudioOpenCoordinatorCloseSequenceTest {
|
|||||||
CompletableFuture<Void> close = StudioOpenCoordinator.sequenceStudioClose(
|
CompletableFuture<Void> close = StudioOpenCoordinator.sequenceStudioClose(
|
||||||
() -> phase(phases, "evacuate"),
|
() -> phase(phases, "evacuate"),
|
||||||
() -> phases.add("maintenance"),
|
() -> phases.add("maintenance"),
|
||||||
|
() -> phase(phases, "quiesce-updates"),
|
||||||
() -> {
|
() -> {
|
||||||
phases.add("unload");
|
phases.add("unload");
|
||||||
return unload;
|
return unload;
|
||||||
@@ -126,13 +177,13 @@ public class StudioOpenCoordinatorCloseSequenceTest {
|
|||||||
warning.get().run();
|
warning.get().run();
|
||||||
assertFalse(close.isDone());
|
assertFalse(close.isDone());
|
||||||
assertFalse(unload.isDone());
|
assertFalse(unload.isDone());
|
||||||
assertEquals(List.of("evacuate", "maintenance", "unload"), phases);
|
assertEquals(List.of("evacuate", "maintenance", "quiesce-updates", "unload"), phases);
|
||||||
configurator.verifyNoInteractions();
|
configurator.verifyNoInteractions();
|
||||||
|
|
||||||
unload.complete(null);
|
unload.complete(null);
|
||||||
close.join();
|
close.join();
|
||||||
|
|
||||||
assertEquals(List.of("evacuate", "maintenance", "unload", "close-generator", "delete-folders"), phases);
|
assertEquals(List.of("evacuate", "maintenance", "quiesce-updates", "unload", "close-generator", "delete-folders"), phases);
|
||||||
configurator.verifyNoInteractions();
|
configurator.verifyNoInteractions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,32 @@ import static org.junit.Assert.assertTrue;
|
|||||||
import static org.mockito.Mockito.mockStatic;
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
|
||||||
public class GenerationHistoryTest extends GenerationHistorySupport {
|
public class GenerationHistoryTest extends GenerationHistorySupport {
|
||||||
|
@Test
|
||||||
|
public void cutoffActivationKeepsItsRecipeAcrossReloadAndLaterBlendedUpdates() throws Exception {
|
||||||
|
Path world = temporaryFolder.newFolder("cutoff-update-world").toPath();
|
||||||
|
Path packA = createPack("cutoff-update-a", "alpha");
|
||||||
|
Path packB = createPack("cutoff-update-b", "beta");
|
||||||
|
GenerationHistory history = createHistory(world, packA);
|
||||||
|
int[][] chunks = {{0, 0}};
|
||||||
|
writeRegion(Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"), chunks);
|
||||||
|
history.stageUpdate(packB, fingerprint(packB), contract(), GenerationRegistryContract.empty(), 0);
|
||||||
|
history.promotePending(signaturesForChunks(chunks));
|
||||||
|
|
||||||
|
GenerationHistory restarted = GenerationHistory.open(world);
|
||||||
|
TransitionGenerationPlan cutoff = restarted.transitionPlan(2L);
|
||||||
|
|
||||||
|
assertEquals(0, restarted.activeActivation().transition().widthBlocks());
|
||||||
|
assertEquals(1D, cutoff.newEpochWeightAt(16, 8), 0D);
|
||||||
|
assertFalse(cutoff.hasTransitionAtChunk(1, 0));
|
||||||
|
assertEquals(1L, restarted.resolveActivation(0, 0).activationId());
|
||||||
|
restarted.stageUpdate(packA, fingerprint(packA), contract(), GenerationRegistryContract.empty(), 32);
|
||||||
|
restarted.promotePending(signaturesForChunks(chunks));
|
||||||
|
|
||||||
|
assertEquals(0, restarted.transitionPlan(2L).widthBlocks());
|
||||||
|
assertEquals(32, restarted.transitionPlan(3L).widthBlocks());
|
||||||
|
assertTrue(restarted.transitionPlan(3L).newEpochWeightAt(16, 8) < 1D);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void freshPublicationReusesOnlyItsInternalVerification() throws Exception {
|
public void freshPublicationReusesOnlyItsInternalVerification() throws Exception {
|
||||||
Path world = temporaryFolder.newFolder("verified-publication-world").toPath();
|
Path world = temporaryFolder.newFolder("verified-publication-world").toPath();
|
||||||
|
|||||||
@@ -69,6 +69,17 @@ public class GenerationTransitionTest {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void zeroWidthCutoffSurvivesActivationRoundTrip() {
|
||||||
|
GenerationActivation activation = GenerationActivation.next(2L, BOUNDARY, 1L, 99L, 0)
|
||||||
|
.completeTransition(BOUNDARY, TERRAIN);
|
||||||
|
|
||||||
|
GenerationActivation restored = GenerationActivation.fromJson(activation.toJson());
|
||||||
|
|
||||||
|
assertEquals(activation, restored);
|
||||||
|
assertEquals(0, restored.transition().widthBlocks());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void activationRoundTripRetainsItsTransition() {
|
public void activationRoundTripRetainsItsTransition() {
|
||||||
GenerationActivation activation = GenerationActivation.next(2L, BOUNDARY, 1L, 99L, 256)
|
GenerationActivation activation = GenerationActivation.next(2L, BOUNDARY, 1L, 99L, 256)
|
||||||
|
|||||||
+190
-1
@@ -5,11 +5,22 @@ import art.arcane.iris.generation.runtime.EnginePlatformHooks;
|
|||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
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.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -60,8 +71,186 @@ public class IrisBoundarySignatureSamplerTest {
|
|||||||
verify(hooks, never()).flushSavedTerrainCapture(engine);
|
verify(hooks, never()).flushSavedTerrainCapture(engine);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void submitsTheWholeBatchBeforeWaitingForAnUnresolvedChunk() throws Exception {
|
||||||
|
IrisEngine engine = mock(IrisEngine.class);
|
||||||
|
EnginePlatformHooks hooks = mock(EnginePlatformHooks.class);
|
||||||
|
when(engine.getPlatformHooks()).thenReturn(hooks);
|
||||||
|
CompletableFuture<SavedTerrainChunk> first = new CompletableFuture<>();
|
||||||
|
CompletableFuture<SavedTerrainChunk> second = new CompletableFuture<>();
|
||||||
|
when(hooks.captureSavedTerrainChunk(engine, -2, 0)).thenReturn(first);
|
||||||
|
when(hooks.captureSavedTerrainChunk(engine, -1, 0)).thenReturn(second);
|
||||||
|
when(hooks.flushSavedTerrainCapture(engine)).thenReturn(CompletableFuture.completedFuture(null));
|
||||||
|
try (TerrainBoundarySignatureStore.SignatureSampler capture = IrisBoundarySignatureSampler.INSTANCE.open(engine);
|
||||||
|
ExecutorService worker = Executors.newSingleThreadExecutor()) {
|
||||||
|
Future<?> prepared = worker.submit(() -> {
|
||||||
|
capture.prepareChunks(List.of(new GenerationBoundary.ChunkCoordinate(-2, 0),
|
||||||
|
new GenerationBoundary.ChunkCoordinate(-1, 0)));
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
prepared.get(3, TimeUnit.SECONDS);
|
||||||
|
verify(hooks).captureSavedTerrainChunk(engine, -2, 0);
|
||||||
|
verify(hooks).captureSavedTerrainChunk(engine, -1, 0);
|
||||||
|
assertFalse(first.isDone());
|
||||||
|
assertFalse(second.isDone());
|
||||||
|
Future<TerrainBoundarySignature> sampled = worker.submit(() -> capture.sample(-32, 8));
|
||||||
|
SavedTerrainChunk firstTerrain = terrain(-2, 0);
|
||||||
|
second.complete(terrain(-1, 0));
|
||||||
|
assertFalse(sampled.isDone());
|
||||||
|
first.complete(firstTerrain);
|
||||||
|
assertSame(firstTerrain.column(-32, 8), sampled.get(3, TimeUnit.SECONDS));
|
||||||
|
assertEquals("example:physical", capture.sample(-16, 8).biomeAtSample(0));
|
||||||
|
verify(hooks, never()).flushSavedTerrainCapture(engine);
|
||||||
|
}
|
||||||
|
verify(hooks).flushSavedTerrainCapture(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void preparingFullWindowsDoesNotEvictAnyCaptureBeforeItIsSampled() throws Exception {
|
||||||
|
IrisEngine engine = mock(IrisEngine.class);
|
||||||
|
EnginePlatformHooks hooks = mock(EnginePlatformHooks.class);
|
||||||
|
when(engine.getPlatformHooks()).thenReturn(hooks);
|
||||||
|
List<CompletableFuture<SavedTerrainChunk>> requests = new ArrayList<>();
|
||||||
|
when(hooks.captureSavedTerrainChunk(eq(engine), anyInt(), eq(0))).thenAnswer(invocation -> {
|
||||||
|
CompletableFuture<SavedTerrainChunk> capture = new CompletableFuture<>();
|
||||||
|
requests.add(capture);
|
||||||
|
return capture;
|
||||||
|
});
|
||||||
|
when(hooks.flushSavedTerrainCapture(engine)).thenReturn(CompletableFuture.completedFuture(null));
|
||||||
|
try (TerrainBoundarySignatureStore.SignatureSampler capture = IrisBoundarySignatureSampler.INSTANCE.open(engine);
|
||||||
|
ExecutorService worker = Executors.newSingleThreadExecutor()) {
|
||||||
|
for (int window = 0; window < 2; window++) {
|
||||||
|
List<GenerationBoundary.ChunkCoordinate> coordinates = new ArrayList<>(32);
|
||||||
|
int first = window * 32;
|
||||||
|
for (int index = 0; index < 32; index++) {
|
||||||
|
coordinates.add(new GenerationBoundary.ChunkCoordinate(first + index, 0));
|
||||||
|
}
|
||||||
|
worker.submit(() -> {
|
||||||
|
capture.prepareChunks(List.copyOf(coordinates));
|
||||||
|
return null;
|
||||||
|
}).get(3, TimeUnit.SECONDS);
|
||||||
|
assertEquals(first + 32, requests.size());
|
||||||
|
for (int index = first; index < first + 32; index++) {
|
||||||
|
assertFalse(requests.get(index).isDone());
|
||||||
|
}
|
||||||
|
for (int index = first; index < first + 32; index++) {
|
||||||
|
SavedTerrainChunk terrain = terrain(index, 0);
|
||||||
|
assertTrue(requests.get(index).complete(terrain));
|
||||||
|
assertSame(terrain.column(index * 16, 0), capture.sample(index * 16, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(64, requests.size());
|
||||||
|
for (CompletableFuture<SavedTerrainChunk> request : requests) {
|
||||||
|
assertFalse(request.isCancelled());
|
||||||
|
}
|
||||||
|
verify(hooks, never()).flushSavedTerrainCapture(engine);
|
||||||
|
}
|
||||||
|
verify(hooks).flushSavedTerrainCapture(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void failedBatchCaptureCancelsPendingChunksBeforeTheFinalCheckpoint() throws Exception {
|
||||||
|
IrisEngine engine = mock(IrisEngine.class);
|
||||||
|
EnginePlatformHooks hooks = mock(EnginePlatformHooks.class);
|
||||||
|
when(engine.getPlatformHooks()).thenReturn(hooks);
|
||||||
|
IOException failure = new IOException("capture failed");
|
||||||
|
CompletableFuture<SavedTerrainChunk> pending = new CompletableFuture<>();
|
||||||
|
when(hooks.captureSavedTerrainChunk(engine, -2, 0)).thenReturn(CompletableFuture.failedFuture(failure));
|
||||||
|
when(hooks.captureSavedTerrainChunk(engine, -1, 0)).thenReturn(pending);
|
||||||
|
when(hooks.flushSavedTerrainCapture(engine)).thenAnswer(invocation -> {
|
||||||
|
assertTrue(pending.isCancelled());
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
|
});
|
||||||
|
try (TerrainBoundarySignatureStore.SignatureSampler capture = IrisBoundarySignatureSampler.INSTANCE.open(engine)) {
|
||||||
|
capture.prepareChunks(List.of(new GenerationBoundary.ChunkCoordinate(-2, 0),
|
||||||
|
new GenerationBoundary.ChunkCoordinate(-1, 0)));
|
||||||
|
IOException propagated = assertThrows(IOException.class, () -> capture.sample(-32, 8));
|
||||||
|
assertSame(failure, propagated.getCause().getCause());
|
||||||
|
assertTrue(pending.isCancelled());
|
||||||
|
verify(hooks, never()).flushSavedTerrainCapture(engine);
|
||||||
|
}
|
||||||
|
verify(hooks).flushSavedTerrainCapture(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void failedBatchSubmissionCancelsRequestsAlreadySubmitted() throws Exception {
|
||||||
|
IrisEngine engine = mock(IrisEngine.class);
|
||||||
|
EnginePlatformHooks hooks = mock(EnginePlatformHooks.class);
|
||||||
|
when(engine.getPlatformHooks()).thenReturn(hooks);
|
||||||
|
CompletableFuture<SavedTerrainChunk> pending = new CompletableFuture<>();
|
||||||
|
IllegalStateException failure = new IllegalStateException("capture submission failed");
|
||||||
|
when(hooks.captureSavedTerrainChunk(engine, -2, 0)).thenReturn(pending);
|
||||||
|
when(hooks.captureSavedTerrainChunk(engine, -1, 0)).thenThrow(failure);
|
||||||
|
when(hooks.flushSavedTerrainCapture(engine)).thenReturn(CompletableFuture.completedFuture(null));
|
||||||
|
try (TerrainBoundarySignatureStore.SignatureSampler capture = IrisBoundarySignatureSampler.INSTANCE.open(engine)) {
|
||||||
|
assertSame(failure, assertThrows(IllegalStateException.class, () -> capture.prepareChunks(List.of(
|
||||||
|
new GenerationBoundary.ChunkCoordinate(-2, 0), new GenerationBoundary.ChunkCoordinate(-1, 0)))));
|
||||||
|
assertTrue(pending.isCancelled());
|
||||||
|
}
|
||||||
|
verify(hooks).flushSavedTerrainCapture(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void pendingCacheIsBoundedAndCloseCancelsRemainingRequests() throws Exception {
|
||||||
|
IrisEngine engine = mock(IrisEngine.class);
|
||||||
|
EnginePlatformHooks hooks = mock(EnginePlatformHooks.class);
|
||||||
|
when(engine.getPlatformHooks()).thenReturn(hooks);
|
||||||
|
List<CompletableFuture<SavedTerrainChunk>> requests = new ArrayList<>();
|
||||||
|
when(hooks.captureSavedTerrainChunk(eq(engine), anyInt(), eq(0))).thenAnswer(invocation -> {
|
||||||
|
CompletableFuture<SavedTerrainChunk> future = new CompletableFuture<>();
|
||||||
|
requests.add(future);
|
||||||
|
return future;
|
||||||
|
});
|
||||||
|
when(hooks.flushSavedTerrainCapture(engine)).thenAnswer(invocation -> {
|
||||||
|
for (CompletableFuture<SavedTerrainChunk> request : requests) {
|
||||||
|
assertTrue(request.isCancelled());
|
||||||
|
}
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
|
});
|
||||||
|
try (TerrainBoundarySignatureStore.SignatureSampler capture = IrisBoundarySignatureSampler.INSTANCE.open(engine)) {
|
||||||
|
for (int chunkX = 0; chunkX < 40; chunkX++) {
|
||||||
|
capture.prepareChunks(List.of(new GenerationBoundary.ChunkCoordinate(chunkX, 0)));
|
||||||
|
int pendingCount = 0;
|
||||||
|
for (CompletableFuture<SavedTerrainChunk> request : requests) {
|
||||||
|
if (!request.isDone()) {
|
||||||
|
pendingCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(Math.min(chunkX + 1, 32), pendingCount);
|
||||||
|
}
|
||||||
|
assertEquals(40, requests.size());
|
||||||
|
assertTrue(requests.get(7).isCancelled());
|
||||||
|
assertFalse(requests.get(8).isDone());
|
||||||
|
verify(hooks, never()).flushSavedTerrainCapture(engine);
|
||||||
|
}
|
||||||
|
verify(hooks).flushSavedTerrainCapture(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void preparationDeduplicatesRequestsAndRejectsOversizedBatches() throws Exception {
|
||||||
|
IrisEngine engine = mock(IrisEngine.class);
|
||||||
|
EnginePlatformHooks hooks = mock(EnginePlatformHooks.class);
|
||||||
|
when(engine.getPlatformHooks()).thenReturn(hooks);
|
||||||
|
CompletableFuture<SavedTerrainChunk> pending = new CompletableFuture<>();
|
||||||
|
when(hooks.captureSavedTerrainChunk(engine, -2, 0)).thenReturn(pending);
|
||||||
|
when(hooks.flushSavedTerrainCapture(engine)).thenReturn(CompletableFuture.completedFuture(null));
|
||||||
|
GenerationBoundary.ChunkCoordinate coordinate = new GenerationBoundary.ChunkCoordinate(-2, 0);
|
||||||
|
try (TerrainBoundarySignatureStore.SignatureSampler capture = IrisBoundarySignatureSampler.INSTANCE.open(engine)) {
|
||||||
|
capture.prepareChunks(List.of(coordinate, coordinate));
|
||||||
|
capture.prepareChunks(List.of(coordinate));
|
||||||
|
verify(hooks).captureSavedTerrainChunk(engine, -2, 0);
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> capture.prepareChunks(Collections.nCopies(33, coordinate)));
|
||||||
|
}
|
||||||
|
assertTrue(pending.isCancelled());
|
||||||
|
}
|
||||||
|
|
||||||
private static SavedTerrainChunk terrain() throws IOException {
|
private static SavedTerrainChunk terrain() throws IOException {
|
||||||
return SavedTerrainChunk.captureBoundary(-2, 0, -16, 16, "minecraft:noise", new SavedTerrainChunk.VoxelSource() {
|
return terrain(-2, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SavedTerrainChunk terrain(int chunkX, int chunkZ) throws IOException {
|
||||||
|
return SavedTerrainChunk.captureBoundary(chunkX, chunkZ, -16, 16, "minecraft:noise", new SavedTerrainChunk.VoxelSource() {
|
||||||
@Override
|
@Override
|
||||||
public BoundaryColumnGeometry.Voxel voxel(int localX, int y, int localZ) {
|
public BoundaryColumnGeometry.Voxel voxel(int localX, int y, int localZ) {
|
||||||
return new BoundaryColumnGeometry.Voxel("minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false);
|
return new BoundaryColumnGeometry.Voxel("minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false);
|
||||||
|
|||||||
+157
@@ -14,6 +14,8 @@ import java.nio.file.Path;
|
|||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.OptionalInt;
|
import java.util.OptionalInt;
|
||||||
import java.util.concurrent.CyclicBarrier;
|
import java.util.concurrent.CyclicBarrier;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
@@ -24,6 +26,7 @@ import java.util.zip.CRC32;
|
|||||||
import static org.junit.Assert.assertArrayEquals;
|
import static org.junit.Assert.assertArrayEquals;
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
import static org.junit.Assert.assertFalse;
|
import static org.junit.Assert.assertFalse;
|
||||||
|
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.junit.Assert.assertTrue;
|
||||||
|
|
||||||
@@ -31,6 +34,160 @@ public class TerrainBoundarySignatureStoreTest {
|
|||||||
@Rule
|
@Rule
|
||||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void preparesOnlyDistinctSampledChunksInImmutableBoundedWindows() throws Exception {
|
||||||
|
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(
|
||||||
|
temporaryFolder.newFolder("prepared-cells").toPath());
|
||||||
|
List<GenerationBoundary.ChunkCoordinate> coordinates = List.of(
|
||||||
|
new GenerationBoundary.ChunkCoordinate(-2, -2),
|
||||||
|
new GenerationBoundary.ChunkCoordinate(-1, -1),
|
||||||
|
new GenerationBoundary.ChunkCoordinate(-2, 2),
|
||||||
|
new GenerationBoundary.ChunkCoordinate(0, 0),
|
||||||
|
new GenerationBoundary.ChunkCoordinate(0, 1),
|
||||||
|
new GenerationBoundary.ChunkCoordinate(1, 0),
|
||||||
|
new GenerationBoundary.ChunkCoordinate(1, 1));
|
||||||
|
GenerationBoundary boundary = GenerationBoundary.freeze("a".repeat(64), coordinates);
|
||||||
|
List<List<GenerationBoundary.ChunkCoordinate>> batches = new ArrayList<>();
|
||||||
|
Set<GenerationBoundary.ChunkCoordinate> sampled = new HashSet<>();
|
||||||
|
TerrainBoundarySignatureStore.SignatureSampler sampler = new TerrainBoundarySignatureStore.SignatureSampler() {
|
||||||
|
private List<GenerationBoundary.ChunkCoordinate> prepared = List.of();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void prepareChunks(List<GenerationBoundary.ChunkCoordinate> chunks) {
|
||||||
|
assertTrue(chunks.size() <= 32);
|
||||||
|
assertEquals(chunks.size(), new HashSet<>(chunks).size());
|
||||||
|
assertThrows(UnsupportedOperationException.class,
|
||||||
|
() -> chunks.add(new GenerationBoundary.ChunkCoordinate(100, 100)));
|
||||||
|
batches.add(chunks);
|
||||||
|
prepared = chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TerrainBoundarySignature sample(int blockX, int blockZ) {
|
||||||
|
GenerationBoundary.ChunkCoordinate chunk = new GenerationBoundary.ChunkCoordinate(
|
||||||
|
Math.floorDiv(blockX, 16), Math.floorDiv(blockZ, 16));
|
||||||
|
assertTrue(prepared.contains(chunk));
|
||||||
|
sampled.add(chunk);
|
||||||
|
return simpleSignature(blockX, blockZ, 70);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
store.publish(1L, boundary, sampler);
|
||||||
|
|
||||||
|
assertEquals(List.of(List.of(
|
||||||
|
new GenerationBoundary.ChunkCoordinate(-2, -2), new GenerationBoundary.ChunkCoordinate(-1, -1),
|
||||||
|
new GenerationBoundary.ChunkCoordinate(-2, 2), new GenerationBoundary.ChunkCoordinate(0, 0),
|
||||||
|
new GenerationBoundary.ChunkCoordinate(0, 1), new GenerationBoundary.ChunkCoordinate(1, 0),
|
||||||
|
new GenerationBoundary.ChunkCoordinate(1, 1))), batches);
|
||||||
|
assertEquals(new HashSet<>(coordinates), sampled);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void preparesAtMostEightCellsBeforeSamplingTheNextWindow() throws Exception {
|
||||||
|
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(
|
||||||
|
temporaryFolder.newFolder("bounded-windows").toPath());
|
||||||
|
List<GenerationBoundary.ChunkCoordinate> coordinates = new ArrayList<>();
|
||||||
|
for (int cell = 0; cell < 9; cell++) {
|
||||||
|
int minimumX = cell * 4 - 16;
|
||||||
|
for (int localX = 0; localX < 2; localX++) {
|
||||||
|
for (int chunkZ = -2; chunkZ < 0; chunkZ++) {
|
||||||
|
coordinates.add(new GenerationBoundary.ChunkCoordinate(minimumX + localX, chunkZ));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GenerationBoundary boundary = GenerationBoundary.freeze("d".repeat(64), coordinates);
|
||||||
|
List<List<GenerationBoundary.ChunkCoordinate>> batches = new ArrayList<>();
|
||||||
|
Set<GenerationBoundary.ChunkCoordinate> sampled = new HashSet<>();
|
||||||
|
TerrainBoundarySignatureStore.SignatureSampler sampler = new TerrainBoundarySignatureStore.SignatureSampler() {
|
||||||
|
private List<GenerationBoundary.ChunkCoordinate> prepared = List.of();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void prepareChunks(List<GenerationBoundary.ChunkCoordinate> chunks) {
|
||||||
|
assertEquals(batches.size() * 32, sampled.size());
|
||||||
|
assertTrue(chunks.size() <= 32);
|
||||||
|
assertEquals(chunks.size(), new HashSet<>(chunks).size());
|
||||||
|
batches.add(chunks);
|
||||||
|
prepared = chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TerrainBoundarySignature sample(int blockX, int blockZ) {
|
||||||
|
GenerationBoundary.ChunkCoordinate chunk = new GenerationBoundary.ChunkCoordinate(
|
||||||
|
Math.floorDiv(blockX, 16), Math.floorDiv(blockZ, 16));
|
||||||
|
assertTrue(prepared.contains(chunk));
|
||||||
|
sampled.add(chunk);
|
||||||
|
return simpleSignature(blockX, blockZ, 70);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
TerrainBoundarySignatureStore.Snapshot actual = store.publish(3L, boundary, sampler);
|
||||||
|
TerrainBoundarySignatureStore sequentialStore = new TerrainBoundarySignatureStore(
|
||||||
|
temporaryFolder.newFolder("bounded-windows-sequential").toPath());
|
||||||
|
TerrainBoundarySignatureStore.Snapshot expected = sequentialStore.publish(3L, boundary,
|
||||||
|
(blockX, blockZ) -> simpleSignature(blockX, blockZ, 70));
|
||||||
|
|
||||||
|
assertEquals(List.of(List.copyOf(coordinates.subList(0, 32)), List.copyOf(coordinates.subList(32, 36))), batches);
|
||||||
|
assertEquals(new HashSet<>(coordinates), sampled);
|
||||||
|
assertEquals(expected.identity(), actual.identity());
|
||||||
|
assertArrayEquals(Files.readAllBytes(sequentialStore.snapshotPath(3L)), Files.readAllBytes(store.snapshotPath(3L)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void preparingChunksPreservesExactSnapshotBytesAndSignatures() throws Exception {
|
||||||
|
TerrainBoundarySignatureStore preparedStore = new TerrainBoundarySignatureStore(
|
||||||
|
temporaryFolder.newFolder("prepared-snapshot").toPath());
|
||||||
|
TerrainBoundarySignatureStore sequentialStore = new TerrainBoundarySignatureStore(
|
||||||
|
temporaryFolder.newFolder("sequential-snapshot").toPath());
|
||||||
|
GenerationBoundary boundary = GenerationBoundary.freeze("b".repeat(64), List.of(
|
||||||
|
new GenerationBoundary.ChunkCoordinate(-2, -1), new GenerationBoundary.ChunkCoordinate(-1, -1),
|
||||||
|
new GenerationBoundary.ChunkCoordinate(0, 0), new GenerationBoundary.ChunkCoordinate(1, 0)));
|
||||||
|
List<GenerationBoundary.ChunkCoordinate> prepared = new ArrayList<>();
|
||||||
|
TerrainBoundarySignatureStore.SignatureSampler sampler = new TerrainBoundarySignatureStore.SignatureSampler() {
|
||||||
|
@Override
|
||||||
|
public void prepareChunks(List<GenerationBoundary.ChunkCoordinate> chunks) {
|
||||||
|
prepared.addAll(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TerrainBoundarySignature sample(int blockX, int blockZ) {
|
||||||
|
return simpleSignature(blockX, blockZ, 70);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
TerrainBoundarySignatureStore.Snapshot actual = preparedStore.publish(7L, boundary, sampler);
|
||||||
|
TerrainBoundarySignatureStore.Snapshot expected = sequentialStore.publish(7L, boundary,
|
||||||
|
(blockX, blockZ) -> simpleSignature(blockX, blockZ, 70));
|
||||||
|
|
||||||
|
assertEquals(4, prepared.size());
|
||||||
|
assertEquals(expected.identity(), actual.identity());
|
||||||
|
assertArrayEquals(Files.readAllBytes(sequentialStore.snapshotPath(7L)),
|
||||||
|
Files.readAllBytes(preparedStore.snapshotPath(7L)));
|
||||||
|
boundary.forEachExposedBlockColumn((blockX, blockZ) -> assertSignatureEquals(
|
||||||
|
expected.signatureAt(blockX, blockZ).orElseThrow(), actual.signatureAt(blockX, blockZ).orElseThrow()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void preparationFailurePreventsSamplingAndCatalogPublication() throws Exception {
|
||||||
|
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(
|
||||||
|
temporaryFolder.newFolder("failed-preparation").toPath());
|
||||||
|
GenerationBoundary boundary = GenerationBoundary.freeze("c".repeat(64),
|
||||||
|
List.of(new GenerationBoundary.ChunkCoordinate(0, 0)));
|
||||||
|
IOException failure = new IOException("chunk preparation failed");
|
||||||
|
TerrainBoundarySignatureStore.SignatureSampler sampler = new TerrainBoundarySignatureStore.SignatureSampler() {
|
||||||
|
@Override
|
||||||
|
public void prepareChunks(List<GenerationBoundary.ChunkCoordinate> chunks) throws IOException {
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TerrainBoundarySignature sample(int blockX, int blockZ) {
|
||||||
|
throw new AssertionError("Sampling continued after preparation failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assertSame(failure, assertThrows(IOException.class, () -> store.publish(1L, boundary, sampler)));
|
||||||
|
assertFalse(Files.exists(store.snapshotPath(1L)));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void loadsOnlyRequestedShardsAndValidatesTheirBytesBeforeUse() throws Exception {
|
public void loadsOnlyRequestedShardsAndValidatesTheirBytesBeforeUse() throws Exception {
|
||||||
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(
|
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(
|
||||||
|
|||||||
@@ -77,6 +77,32 @@ public class TransitionGenerationPlanTest {
|
|||||||
assertEquals(1D, plan.newEpochWeightAt(47, 15), 0D);
|
assertEquals(1D, plan.newEpochWeightAt(47, 15), 0D);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void zeroWidthKeepsCutoffsAndHistoricalOwnershipWithoutSamplingOldTerrain() throws Exception {
|
||||||
|
GenerationBoundary boundary = GenerationBoundary.freeze("cutoff", List.of(
|
||||||
|
new GenerationBoundary.ChunkCoordinate(0, 0)));
|
||||||
|
TerrainBoundarySignatureStore.Snapshot snapshot = snapshot(2L, boundary);
|
||||||
|
TransitionGenerationPlan plan = new TransitionGenerationPlan(
|
||||||
|
specification(2L, "old", "new", 0, "cutoff", snapshot.identity()), boundary, snapshot);
|
||||||
|
TransitionDisplacementField field = new TransitionDisplacementField(plan,
|
||||||
|
new TransitionDisplacementField.Sources((x, z) -> 104D, 128));
|
||||||
|
|
||||||
|
assertEquals(0D, plan.newEpochWeightAt(15, 8), 0D);
|
||||||
|
assertEquals(1D, plan.newEpochWeightAt(16, 8), 0D);
|
||||||
|
assertEquals(1D, plan.hydrologyWeightAt(16, 8), 0D);
|
||||||
|
assertFalse(plan.terrainSampleAt(16, 8).hasHistoricalSignature());
|
||||||
|
assertFalse(plan.hasTransitionAtChunk(1, 0));
|
||||||
|
assertEquals(BoundaryGeometryInfluence.none(), plan.geometryAt(16, 8));
|
||||||
|
assertTrue(plan.historicalPhysicalBiomeKeyAt(16, 64, 8).isEmpty());
|
||||||
|
assertFalse(plan.allowsNewFootprint(15, 8, 16, 8));
|
||||||
|
assertTrue(plan.allowsNewFootprint(16, 8, 17, 8));
|
||||||
|
assertEquals(104D, field.height(16, 8, 104D), 0D);
|
||||||
|
assertEquals(48D, field.fluidHeight(16, 8, 48D), 0D);
|
||||||
|
assertEquals(0L, plan.terrainCatalogProbeCount());
|
||||||
|
assertEquals(0L, plan.terrainShardLoadCount());
|
||||||
|
assertEquals(0L, plan.terrainCandidateBuildCount());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void computesTransitionForEnclosedNewTerritory() throws Exception {
|
public void computesTransitionForEnclosedNewTerritory() throws Exception {
|
||||||
GenerationBoundary boundary = GenerationBoundary.freeze("ring", List.of(
|
GenerationBoundary boundary = GenerationBoundary.freeze("ring", List.of(
|
||||||
@@ -336,7 +362,7 @@ public class TransitionGenerationPlanTest {
|
|||||||
assertThrows(IllegalArgumentException.class,
|
assertThrows(IllegalArgumentException.class,
|
||||||
() -> specification(1L, "same", "same", 16, "actual", snapshot.identity()));
|
() -> specification(1L, "same", "same", 16, "actual", snapshot.identity()));
|
||||||
assertThrows(IllegalArgumentException.class,
|
assertThrows(IllegalArgumentException.class,
|
||||||
() -> specification(1L, "old", "new", 0, "actual", snapshot.identity()));
|
() -> specification(1L, "old", "new", -1, "actual", snapshot.identity()));
|
||||||
assertThrows(IllegalArgumentException.class,
|
assertThrows(IllegalArgumentException.class,
|
||||||
() -> new TransitionGenerationPlan(
|
() -> new TransitionGenerationPlan(
|
||||||
specification(1L, "old", "new", 16, "different", snapshot.identity()),
|
specification(1L, "old", "new", 16, "different", snapshot.identity()),
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ public class IrisPregeneratorInitTest {
|
|||||||
pressure.verify(MantleHeapPressure::requestPanicReclaim);
|
pressure.verify(MantleHeapPressure::requestPanicReclaim);
|
||||||
if (failReclaim) {
|
if (failReclaim) {
|
||||||
logging.verify(() -> IrisLogging.reportError(
|
logging.verify(() -> IrisLogging.reportError(
|
||||||
"Pregen could not reclaim completed server chunks under heap pressure.", failure));
|
"Pregen could not reclaim completed server chunks under memory pressure.", failure));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package art.arcane.iris.world.pregen;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public class MantleContainerPressureTest {
|
||||||
|
@Test
|
||||||
|
public void containerPressureReclaimsEvenWhenTheHeapHasHeadroom() {
|
||||||
|
double pressure = MantleHeapPressure.pressureFraction(0.45D, 0.90D);
|
||||||
|
assertEquals(0.92D, pressure, 1E-9D);
|
||||||
|
assertEquals(1D, MantleHeapPressure.reclaimUrgency(pressure), 0D);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void heapPressureStillControlsReclaimOutsideContainers() {
|
||||||
|
assertEquals(0.95D, MantleHeapPressure.pressureFraction(0.95D, 0D), 0D);
|
||||||
|
assertEquals(0.45D, MantleHeapPressure.pressureFraction(0.45D, 0D), 0D);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void containerBudgetsReserveMemoryOutsideTheHeap() {
|
||||||
|
assertEquals(3L << 30, MantleHeapPressure.memoryBudgetBytes(8L << 30, 4L << 30));
|
||||||
|
assertEquals(2L << 30, MantleHeapPressure.memoryBudgetBytes(2L << 30, 4L << 30));
|
||||||
|
assertEquals(8L << 30, MantleHeapPressure.memoryBudgetBytes(8L << 30, Long.MAX_VALUE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void containerPressureCannotResumeBecauseAWarningDeadlineExpired() {
|
||||||
|
AtomicLong clock = new AtomicLong();
|
||||||
|
MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate(
|
||||||
|
0.90D, 0.80D, Long.MAX_VALUE, clock::get, () -> {});
|
||||||
|
assertTrue(gate.update(0.95D));
|
||||||
|
clock.set(600_000L);
|
||||||
|
assertTrue(gate.update(0.85D));
|
||||||
|
assertFalse(gate.update(0.80D));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package art.arcane.iris.world.task;
|
||||||
|
|
||||||
|
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||||
|
import art.arcane.iris.spi.IrisPlatform;
|
||||||
|
import art.arcane.iris.spi.IrisPlatforms;
|
||||||
|
import art.arcane.iris.testsupport.PlatformLeakGuard;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
import org.junit.After;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.ClassRule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||||
|
|
||||||
|
public class JRegionTaskAdmissionTest {
|
||||||
|
@ClassRule
|
||||||
|
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void clearPlatform() {
|
||||||
|
IrisPlatforms.unbind();
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void cleanup() {
|
||||||
|
IrisPlatforms.unbind();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void missingPlatformRejectsRegionTasks() {
|
||||||
|
World world = mock(World.class);
|
||||||
|
AtomicBoolean ran = new AtomicBoolean();
|
||||||
|
|
||||||
|
assertFalse(J.runRegion(world, 0, 0, () -> ran.set(true)));
|
||||||
|
assertFalse(J.runRegion(world, 0, 0, () -> ran.set(true), 1));
|
||||||
|
assertFalse(ran.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void disabledBukkitPluginRejectsRegionTasks() {
|
||||||
|
IrisPlatform platform = mock(IrisPlatform.class);
|
||||||
|
when(platform.platformName()).thenReturn("bukkit");
|
||||||
|
IrisPlatforms.bind(platform);
|
||||||
|
Plugin plugin = mock(Plugin.class);
|
||||||
|
when(plugin.isEnabled()).thenReturn(false);
|
||||||
|
World world = mock(World.class);
|
||||||
|
AtomicBoolean ran = new AtomicBoolean();
|
||||||
|
|
||||||
|
try (MockedStatic<BukkitPlatform> bukkit = mockStatic(BukkitPlatform.class);
|
||||||
|
MockedStatic<Bukkit> server = mockStatic(Bukkit.class)) {
|
||||||
|
bukkit.when(BukkitPlatform::hasPlugin).thenReturn(true);
|
||||||
|
bukkit.when(BukkitPlatform::plugin).thenReturn(plugin);
|
||||||
|
|
||||||
|
assertFalse(J.runRegion(world, 0, 0, () -> ran.set(true)));
|
||||||
|
assertFalse(J.runRegion(world, 0, 0, () -> ran.set(true), 1));
|
||||||
|
CompletableFuture<Void> future = J.runRegionFuture(world, 0, 0, () -> ran.set(true));
|
||||||
|
|
||||||
|
assertTrue(future.isCompletedExceptionally());
|
||||||
|
assertFalse(ran.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void ownedFoliaCleanupStillRunsWithoutAScheduler() {
|
||||||
|
World world = mock(World.class);
|
||||||
|
AtomicBoolean ran = new AtomicBoolean();
|
||||||
|
|
||||||
|
try (MockedStatic<J> scheduling = mockStatic(J.class, CALLS_REAL_METHODS)) {
|
||||||
|
scheduling.when(J::isFolia).thenReturn(true);
|
||||||
|
scheduling.when(() -> J.isOwnedByCurrentRegion(world, 0, 0)).thenReturn(true);
|
||||||
|
|
||||||
|
assertTrue(J.runRegion(world, 0, 0, () -> ran.set(true)));
|
||||||
|
assertTrue(ran.get());
|
||||||
|
ran.set(false);
|
||||||
|
assertTrue(J.runRegion(world, 0, 0, () -> ran.set(true), 0));
|
||||||
|
assertTrue(ran.get());
|
||||||
|
ran.set(false);
|
||||||
|
CompletableFuture<Void> future = J.runRegionFuture(world, 0, 0, () -> ran.set(true));
|
||||||
|
assertFalse(future.isCompletedExceptionally());
|
||||||
|
assertTrue(ran.get());
|
||||||
|
ran.set(false);
|
||||||
|
assertFalse(J.runRegion(world, 0, 0, () -> ran.set(true), 1));
|
||||||
|
assertFalse(ran.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user