Actively contain the event horizon so water can never flow out

Scan-time enclosure checking can catch most gaps, but Minecraft's own
fluid physics will still spread a water source block into any
adjacent open space it finds - a diagonal/stepped ring corner can
have a path that isn't a "leak" by the scanner's flood-fill rules
(which only ever move orthogonally) but vanilla water flow still
takes, since flow is also orthogonal-only and can find edge cases the
scanner's enclosure proof didn't need to rule out.

Rather than trying to prove every possible ring geometry is leak-proof
at scan time, GateManager now tracks every iris block of every locally
scanned gate (registered at link time, on restart re-scan, and
unregistered on destroy), and a new BlockFromToEvent listener cancels
any fluid spread whose source is one of those blocks. This is the
same technique real portal-style plugins use: containment enforced by
the plugin at the physics level, not by proving the shape is sound in
advance.

Note: this only stops future leaking. Water that already escaped
before this fix needs to be manually cleaned up once.
This commit is contained in:
Michael Burgess
2026-08-09 13:18:40 -04:00
parent 1b03009fa3
commit 67330744eb
4 changed files with 67 additions and 2 deletions
+4 -1
View File
@@ -65,7 +65,10 @@ material, unsealed interior, too far from the sign, etc.
gates on the same network.
- **Left-click** the sign: dials the shown destination - chevrons light in sequence,
then the interior becomes the event horizon (`gate.iris-open-material`, default
water) and travel opens up. It auto-closes after `dialing.open-seconds`.
water) and travel opens up. It auto-closes after `dialing.open-seconds`. That water
is contained to exactly the iris blocks - the plugin actively cancels any attempt
by Minecraft's own fluid physics to spread it further, so it can't leak out through
a gap or an odd ring shape and flood the ground around the gate.
- **Shift + left-click** the sign: destroys the gate on the spot (same permission
check as `/sg destroy` - owner or `stargate.admin`), breaking the sign as if
you'd mined it. Quicker than looking at the sign and typing the command.
@@ -52,6 +52,7 @@ public class StargatePlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(new StructureProtectListener(gateManager), this);
getServer().getPluginManager().registerEvents(new dev.skywalker3200.stargate.paper.listener.GateTeleportListener(this, gateManager, crossServerBridge), this);
getServer().getPluginManager().registerEvents(new dev.skywalker3200.stargate.paper.listener.GateIrisButtonListener(this, gateManager), this);
getServer().getPluginManager().registerEvents(new dev.skywalker3200.stargate.paper.listener.GateWaterContainmentListener(gateManager), this);
StargateCommand command = new StargateCommand(this, gateManager);
getCommand("stargate").setExecutor(command);
@@ -34,6 +34,11 @@ public class GateManager {
private final Map<UUID, RuntimeGate> gatesById = new HashMap<>();
private final Map<String, RuntimeGate> gatesBySignBlock = new HashMap<>(); // "world,x,y,z" -> gate
// Every iris block of every locally-scanned gate, regardless of open/closed - used to stop
// Minecraft's own fluid physics from spreading the event horizon water past exactly these
// blocks, no matter what shape the ring is. Scan-time enclosure checking can't guarantee
// that on its own for every possible geometry, so this is the actual containment.
private final Set<String> irisKeys = new HashSet<>();
public GateManager(StargatePlugin plugin, GateStorage storage) {
this.plugin = plugin;
@@ -52,6 +57,7 @@ public class GateManager {
public void loadAll() {
gatesById.clear();
gatesBySignBlock.clear();
irisKeys.clear();
for (Gate gate : storage.loadAll()) {
GateStructure structure = null;
if (gate.getServerId().equals(plugin.getServerId())) {
@@ -71,7 +77,10 @@ public class GateManager {
// idle - applyIrisMaterial() still shows the shield if it was left closed, since
// that's independent of the wormhole being active
RuntimeGate rg = new RuntimeGate(gate, structure);
if (structure != null) applyIrisMaterial(rg);
if (structure != null) {
applyIrisMaterial(rg);
registerIrisKeys(structure);
}
gatesById.put(gate.getId(), rg);
if (gate.getServerId().equals(plugin.getServerId())) {
gatesBySignBlock.put(signKey(gate.getSignWorld(), gate.getSignX(), gate.getSignY(), gate.getSignZ()), rg);
@@ -231,6 +240,7 @@ public class GateManager {
RuntimeGate rg = new RuntimeGate(gate, structure);
applyIrisMaterial(rg);
registerIrisKeys(structure);
gatesById.put(gate.getId(), rg);
gatesBySignBlock.put(signKey(gate.getSignWorld(), gate.getSignX(), gate.getSignY(), gate.getSignZ()), rg);
storage.saveGate(gate);
@@ -241,11 +251,31 @@ public class GateManager {
public void destroyGate(RuntimeGate rg) {
closeGate(rg);
unregisterIrisKeys(rg.getStructure());
gatesById.remove(rg.getGate().getId());
gatesBySignBlock.remove(signKey(rg.getGate().getSignWorld(), rg.getGate().getSignX(), rg.getGate().getSignY(), rg.getGate().getSignZ()));
storage.deleteGate(rg.getGate().getId());
}
private String blockKey(Block b) {
return b.getWorld().getName() + "," + b.getX() + "," + b.getY() + "," + b.getZ();
}
private void registerIrisKeys(GateStructure structure) {
if (structure == null) return;
for (Block b : structure.getIris()) irisKeys.add(blockKey(b));
}
private void unregisterIrisKeys(GateStructure structure) {
if (structure == null) return;
for (Block b : structure.getIris()) irisKeys.remove(blockKey(b));
}
/** True if this block is a gate's iris - used to stop water from ever flowing out of one. */
public boolean isIrisBlock(Block b) {
return irisKeys.contains(blockKey(b));
}
public void saveGate(RuntimeGate rg) {
storage.saveGate(rg.getGate());
}
@@ -0,0 +1,31 @@
package dev.skywalker3200.stargate.paper.listener;
import dev.skywalker3200.stargate.paper.gate.GateManager;
import org.bukkit.Material;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockFromToEvent;
/**
* Stops the event horizon from ever spreading past exactly the blocks the plugin placed.
* Scan-time enclosure checking can catch most gaps, but Minecraft's own fluid physics will
* still spread water into any adjacent open space it finds - a stepped/diagonal ring corner
* can have a path scan-time checks don't consider a "leak" but vanilla water flow still takes.
* This is the actual containment: cancel any flow whose source is a registered iris block.
*/
public class GateWaterContainmentListener implements Listener {
private final GateManager gateManager;
public GateWaterContainmentListener(GateManager gateManager) {
this.gateManager = gateManager;
}
@EventHandler(ignoreCancelled = true)
public void onFlow(BlockFromToEvent event) {
if (event.getBlock().getType() != Material.WATER) return;
if (gateManager.isIrisBlock(event.getBlock())) {
event.setCancelled(true);
}
}
}