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. gates on the same network.
- **Left-click** the sign: dials the shown destination - chevrons light in sequence, - **Left-click** the sign: dials the shown destination - chevrons light in sequence,
then the interior becomes the event horizon (`gate.iris-open-material`, default 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 - **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 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. 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 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.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.GateIrisButtonListener(this, gateManager), this);
getServer().getPluginManager().registerEvents(new dev.skywalker3200.stargate.paper.listener.GateWaterContainmentListener(gateManager), this);
StargateCommand command = new StargateCommand(this, gateManager); StargateCommand command = new StargateCommand(this, gateManager);
getCommand("stargate").setExecutor(command); getCommand("stargate").setExecutor(command);
@@ -34,6 +34,11 @@ public class GateManager {
private final Map<UUID, RuntimeGate> gatesById = new HashMap<>(); private final Map<UUID, RuntimeGate> gatesById = new HashMap<>();
private final Map<String, RuntimeGate> gatesBySignBlock = new HashMap<>(); // "world,x,y,z" -> gate 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) { public GateManager(StargatePlugin plugin, GateStorage storage) {
this.plugin = plugin; this.plugin = plugin;
@@ -52,6 +57,7 @@ public class GateManager {
public void loadAll() { public void loadAll() {
gatesById.clear(); gatesById.clear();
gatesBySignBlock.clear(); gatesBySignBlock.clear();
irisKeys.clear();
for (Gate gate : storage.loadAll()) { for (Gate gate : storage.loadAll()) {
GateStructure structure = null; GateStructure structure = null;
if (gate.getServerId().equals(plugin.getServerId())) { 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 // idle - applyIrisMaterial() still shows the shield if it was left closed, since
// that's independent of the wormhole being active // that's independent of the wormhole being active
RuntimeGate rg = new RuntimeGate(gate, structure); RuntimeGate rg = new RuntimeGate(gate, structure);
if (structure != null) applyIrisMaterial(rg); if (structure != null) {
applyIrisMaterial(rg);
registerIrisKeys(structure);
}
gatesById.put(gate.getId(), rg); gatesById.put(gate.getId(), rg);
if (gate.getServerId().equals(plugin.getServerId())) { if (gate.getServerId().equals(plugin.getServerId())) {
gatesBySignBlock.put(signKey(gate.getSignWorld(), gate.getSignX(), gate.getSignY(), gate.getSignZ()), rg); 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); RuntimeGate rg = new RuntimeGate(gate, structure);
applyIrisMaterial(rg); applyIrisMaterial(rg);
registerIrisKeys(structure);
gatesById.put(gate.getId(), rg); gatesById.put(gate.getId(), rg);
gatesBySignBlock.put(signKey(gate.getSignWorld(), gate.getSignX(), gate.getSignY(), gate.getSignZ()), rg); gatesBySignBlock.put(signKey(gate.getSignWorld(), gate.getSignX(), gate.getSignY(), gate.getSignZ()), rg);
storage.saveGate(gate); storage.saveGate(gate);
@@ -241,11 +251,31 @@ public class GateManager {
public void destroyGate(RuntimeGate rg) { public void destroyGate(RuntimeGate rg) {
closeGate(rg); closeGate(rg);
unregisterIrisKeys(rg.getStructure());
gatesById.remove(rg.getGate().getId()); gatesById.remove(rg.getGate().getId());
gatesBySignBlock.remove(signKey(rg.getGate().getSignWorld(), rg.getGate().getSignX(), rg.getGate().getSignY(), rg.getGate().getSignZ())); gatesBySignBlock.remove(signKey(rg.getGate().getSignWorld(), rg.getGate().getSignX(), rg.getGate().getSignY(), rg.getGate().getSignZ()));
storage.deleteGate(rg.getGate().getId()); 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) { public void saveGate(RuntimeGate rg) {
storage.saveGate(rg.getGate()); 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);
}
}
}