Add gilded blackstone to default frame materials, report scan failure reasons

Sign creation now tells the player exactly why the structure scan failed
(missing frame material, ring not sealed, interior too large, etc.)
instead of one generic error.
This commit is contained in:
Michael Burgess
2026-08-09 10:08:21 -04:00
parent d651f6586f
commit 56c089f656
4 changed files with 83 additions and 20 deletions
@@ -108,15 +108,30 @@ public class GateManager {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
private String lastCreateFailureReason;
/** Set only when {@link #createGate} just returned null - explains why the scan failed. */
public String getLastCreateFailureReason() {
return lastCreateFailureReason;
}
public RuntimeGate createGate(Block signBlock, String network, String name, UUID owner, EnumSet<Gate.Flag> flags) { public RuntimeGate createGate(Block signBlock, String network, String name, UUID owner, EnumSet<Gate.Flag> flags) {
return createGate(signBlock, network, name, owner, flags, null); return createGate(signBlock, network, name, owner, flags, null);
} }
public RuntimeGate createGate(Block signBlock, String network, String name, UUID owner, EnumSet<Gate.Flag> flags, String fixedDestination) { public RuntimeGate createGate(Block signBlock, String network, String name, UUID owner, EnumSet<Gate.Flag> flags, String fixedDestination) {
lastCreateFailureReason = null;
Block attached = attachedFrameBlock(signBlock); Block attached = attachedFrameBlock(signBlock);
if (attached == null) return null; if (attached == null) {
GateStructure structure = scanner.scan(attached); lastCreateFailureReason = "Couldn't determine which block the sign is attached to.";
if (structure == null) return null; return null;
}
GateStructureScanner.ScanResult result = scanner.scanWithDiagnostics(attached);
if (!result.isSuccess()) {
lastCreateFailureReason = result.failureReason;
return null;
}
GateStructure structure = result.structure;
Location exit = computeExitLocation(structure, signBlock); Location exit = computeExitLocation(structure, signBlock);
Gate gate = new Gate(UUID.randomUUID(), name, network, plugin.getServerId(), Gate gate = new Gate(UUID.randomUUID(), name, network, plugin.getServerId(),
@@ -22,6 +22,22 @@ public class GateStructureScanner {
BlockFace.UP, BlockFace.DOWN, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST BlockFace.UP, BlockFace.DOWN, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST
}; };
/** Result of a scan: either a successful structure, or a reason a player-facing message can be built from. */
public static final class ScanResult {
public final GateStructure structure; // null on failure
public final String failureReason; // null on success
private ScanResult(GateStructure structure, String failureReason) {
this.structure = structure;
this.failureReason = failureReason;
}
static ScanResult ok(GateStructure s) { return new ScanResult(s, null); }
static ScanResult fail(String reason) { return new ScanResult(null, reason); }
public boolean isSuccess() { return structure != null; }
}
private final Set<Material> frameMaterials; private final Set<Material> frameMaterials;
private final Material chevronUnlit; private final Material chevronUnlit;
private final Material chevronLit; private final Material chevronLit;
@@ -47,36 +63,49 @@ public class GateStructureScanner {
return m == chevronUnlit || m == chevronLit; return m == chevronUnlit || m == chevronLit;
} }
/** /** Convenience wrapper for callers that only care about success/failure, not why. */
* Scans outward from the given seed block (the block the sign is attached to).
* Returns null if no valid enclosed structure is found.
*/
public GateStructure scan(Block seed) { public GateStructure scan(Block seed) {
if (!isFrameMaterial(seed.getType())) { return scanWithDiagnostics(seed).structure;
// seed itself may be the wall block behind a sign that's part of a bigger build; }
// try its direct neighbors for the actual frame block.
/**
* Scans outward from the given seed block (the block the sign is attached to) and reports
* exactly why it failed if it did, instead of just returning null.
*/
public ScanResult scanWithDiagnostics(Block seed) {
Block frameSeed = seed;
if (!isFrameMaterial(frameSeed.getType())) {
for (BlockFace face : NEIGHBORS) { for (BlockFace face : NEIGHBORS) {
Block b = seed.getRelative(face); Block b = frameSeed.getRelative(face);
if (isFrameMaterial(b.getType())) { if (isFrameMaterial(b.getType())) {
seed = b; frameSeed = b;
break; break;
} }
} }
} }
if (!isFrameMaterial(seed.getType())) return null; if (!isFrameMaterial(frameSeed.getType())) {
return ScanResult.fail("The sign isn't touching a block listed in gate.frame-materials "
+ "(nor is it touching the chevron material). Check the sign is mounted directly on the frame, "
+ "and that every block type in your ring is listed in config.yml.");
}
Set<Long> frameKeys = new HashSet<>(); Set<Long> frameKeys = new HashSet<>();
List<Block> frameBlocks = new ArrayList<>(); List<Block> frameBlocks = new ArrayList<>();
List<Block> chevronBlocks = new ArrayList<>(); List<Block> chevronBlocks = new ArrayList<>();
Deque<Block> queue = new ArrayDeque<>(); Deque<Block> queue = new ArrayDeque<>();
queue.add(seed); queue.add(frameSeed);
frameKeys.add(key(seed)); frameKeys.add(key(frameSeed));
while (!queue.isEmpty()) { while (!queue.isEmpty()) {
Block cur = queue.poll(); Block cur = queue.poll();
frameBlocks.add(cur); frameBlocks.add(cur);
if (isChevronMaterial(cur.getType())) chevronBlocks.add(cur); if (isChevronMaterial(cur.getType())) chevronBlocks.add(cur);
if (frameBlocks.size() > maxFrameBlocks) return null; if (frameBlocks.size() > maxFrameBlocks) {
return ScanResult.fail("The connected frame has more than gate.max-frame-blocks (" + maxFrameBlocks
+ ") blocks. Either it's too big, or a frame material is leaking into a much larger structure "
+ "(e.g. planks used elsewhere in your build touching the ring). Raise max-frame-blocks or "
+ "make the frame materials more specific.");
}
for (BlockFace face : NEIGHBORS) { for (BlockFace face : NEIGHBORS) {
Block next = cur.getRelative(face); Block next = cur.getRelative(face);
@@ -89,12 +118,17 @@ public class GateStructureScanner {
} }
} }
if (frameBlocks.size() < minFrameBlocks) return null; if (frameBlocks.size() < minFrameBlocks) {
return ScanResult.fail("Only found " + frameBlocks.size() + " connected frame block(s), need at least "
+ minFrameBlocks + " (gate.min-frame-blocks). The ring may be too small or made of a material "
+ "that isn't in gate.frame-materials.");
}
// Find an interior seed: a non-frame block adjacent to a frame block, that is not // Find an interior seed: a non-frame block adjacent to a frame block, that is not
// solid (roughly the middle of the ring). We try several candidates and flood-fill // solid (roughly the middle of the ring). We try several candidates and flood-fill
// each; the first one that stays enclosed within maxIrisBlocks wins. // each; the first one that stays enclosed within maxIrisBlocks wins.
Set<Long> triedSeeds = new HashSet<>(); Set<Long> triedSeeds = new HashSet<>();
boolean triedAnyInterior = false;
for (Block frameBlock : frameBlocks) { for (Block frameBlock : frameBlocks) {
for (BlockFace face : NEIGHBORS) { for (BlockFace face : NEIGHBORS) {
Block candidate = frameBlock.getRelative(face); Block candidate = frameBlock.getRelative(face);
@@ -103,13 +137,22 @@ public class GateStructureScanner {
triedSeeds.add(ck); triedSeeds.add(ck);
if (candidate.getType().isSolid()) continue; if (candidate.getType().isSolid()) continue;
triedAnyInterior = true;
List<Block> iris = floodInterior(candidate, frameKeys); List<Block> iris = floodInterior(candidate, frameKeys);
if (iris != null && !iris.isEmpty()) { if (iris != null && !iris.isEmpty()) {
return new GateStructure(frameBlocks, chevronBlocks, iris); return ScanResult.ok(new GateStructure(frameBlocks, chevronBlocks, iris));
} }
} }
} }
return null;
if (!triedAnyInterior) {
return ScanResult.fail("The frame has no open interior at all (every block touching the ring is solid). "
+ "Leave the middle hollow.");
}
return ScanResult.fail("Found a " + frameBlocks.size() + "-block frame, but its interior isn't fully "
+ "enclosed - the empty space either leaks out through a gap in the ring, or is bigger than "
+ "gate.max-iris-blocks (" + maxIrisBlocks + "). Check for a 1-block hole anywhere in the ring "
+ "(including the floor/ceiling of the arch).");
} }
/** Flood-fills non-frame blocks starting at seed; fails (returns null) if it escapes the frame boundary. */ /** Flood-fills non-frame blocks starting at seed; fails (returns null) if it escapes the frame boundary. */
@@ -60,7 +60,11 @@ public class SignCreateListener implements Listener {
RuntimeGate rg = gateManager.createGate(event.getBlock(), network, name, player.getUniqueId(), flags, fixedDestination); RuntimeGate rg = gateManager.createGate(event.getBlock(), network, name, player.getUniqueId(), flags, fixedDestination);
if (rg == null) { if (rg == null) {
player.sendMessage(Component.text("No valid gate structure found. Build the frame first, then place the sign.", NamedTextColor.RED)); String reason = gateManager.getLastCreateFailureReason();
player.sendMessage(Component.text("No valid gate structure found.", NamedTextColor.RED));
if (reason != null) {
player.sendMessage(Component.text(reason, NamedTextColor.GRAY));
}
resetLine(event); resetLine(event);
return; return;
} }
@@ -31,6 +31,7 @@ gate:
frame-materials: frame-materials:
- OBSIDIAN - OBSIDIAN
- GOLD_BLOCK - GOLD_BLOCK
- GILDED_BLACKSTONE
- BIRCH_PLANKS - BIRCH_PLANKS
- OAK_PLANKS - OAK_PLANKS
# Chevron blocks embedded in the frame. Build them as chevron-unlit-material; the # Chevron blocks embedded in the frame. Build them as chevron-unlit-material; the