ref:2741a8f92938118ba644a07a01e6b2aa190cc058

Rewrite multi-block logic: fix interaction, breaking, and edge cases

Bugs fixed: 1. Clicking non-controller block started rogue terminal with wrong dims (caused Pacman-style corrupted rendering). Fix: onPlayerInteract now does synchronous rescanGroup() BEFORE starting terminal, and rescan kills any rogue terminals on extensions. 2. Breaking blocks in group crashed the game. Fix: onBlockRemoved now clears group state on self BEFORE iterating members, and neighbors get their stale group refs cleared before rescanning. 3. getController() could NPE when controller was destroyed. Fix: if controller BE is missing, resets to standalone and returns self. Critical design change: terminal is ONLY started via explicit player interaction on the controller block. Extensions always delegate. rescanGroup() is idempotent and safe to call multiple times. New tests (12 multi-block wiring tests): - rescanRoles2x1, extensionDelegatesToController - dims2x1, dims2x2, dims3x2 - rescanKillsRogueTerminalOnExtension - breakController2x1, breakExtension2x1, breakMiddle3x1 - breakAllOneByOne, placeBreakReplace - getControllerAfterDestruction 46 GameTests + 52 Rust tests, all passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SHA: 2741a8f92938118ba644a07a01e6b2aa190cc058
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-03-20 14:11
Parents: c55db34
2 files changed +442 -188
Type
common/src/main/java/io/fangorn/alacrittymc/block/TerminalBlockEntity.java +169 −149
@@ -13,40 +13,43 @@
import java.nio.ByteBuffer;
/**
* Block entity that owns and manages a native terminal instance.
* Block entity for the terminal block.
*
* Multi-block design:
* Supports multi-block screens: one block is the "controller" (owns the terminal),
* others are "extensions" that reference the controller and render a sub-region.
* - One block is the "controller" (owns the NativeTerminal + PTY)
* - Others are "extensions" that render sub-regions of the controller's texture
* - Group membership is determined by ScreenGroup.scan() (flood-fill)
* - The controller is always the top-left block in the group (by sort order)
*
* Critical invariant: a terminal is ONLY started on a block that is the
* controller of its group at the moment of activation. If the user clicks
* an extension, it delegates to the controller. This prevents rogue terminals
* on non-controller blocks.
*/
public class TerminalBlockEntity extends BlockEntity {
// Terminal configuration (per-block defaults for single block)
private static final int COLS_PER_BLOCK = 40;
private static final int ROWS_PER_BLOCK = 12;
public static final int COLS_PER_BLOCK = 40;
public static final int ROWS_PER_BLOCK = 12;
private static final int DEFAULT_COLS = 80;
private static final int DEFAULT_ROWS = 24;
private int cols = DEFAULT_COLS;
private int rows = DEFAULT_ROWS;
private int cols = 80;
private int rows = 24;
private float fontSize = 14.0f;
// Native terminal instance (client-side only, only on controller)
@Nullable
private NativeTerminal terminal;
// Pixel data for rendering
@Nullable
@Nullable private NativeTerminal terminal;
@Nullable private ByteBuffer pixelBuffer;
private ByteBuffer pixelBuffer;
private int pixelWidth = 0;
private int pixelHeight = 0;
private boolean textureNeedsUpdate = false;
// State
private boolean terminalStarted = false;
// Multi-block: if this block is an extension, controllerPos points to the controller
@Nullable
private BlockPos controllerPos;
@Nullable
// Multi-block state
@Nullable private BlockPos controllerPos; // null = I am the controller (or standalone)
@Nullable private ScreenGroup screenGroup;
private ScreenGroup screenGroup;
// Client-side screen opener callback
// Client-side lazy group scanning
private int clientTickCount = 0;
private boolean clientGroupScanned = false;
@Nullable
private static java.util.function.Consumer<TerminalBlockEntity> screenOpener;
@@ -55,114 +58,163 @@
super(AlacrittyMod.TERMINAL_BLOCK_ENTITY.get(), pos, state);
}
// ==================== PLAYER INTERACTION ====================
/**
* Called when a player right-clicks this terminal block's screen face.
* Always rescans group first to ensure correct role assignment,
* Called when a player right-clicks the terminal block.
* then delegates to the controller if this is an extension.
*/
public void onPlayerInteract(Player player) {
if (level == null || !level.isClientSide()) return;
// If this is an extension block, redirect to the controller
TerminalBlockEntity controller = getController();
if (controller != this && controller != null) {
controller.onPlayerInteract(player);
return;
// ALWAYS rescan before starting anything — this ensures we know
// our role (controller vs extension) before creating a terminal
rescanGroup();
}
if (!terminalStarted) {
// If we're an extension, delegate to the controller
if (isExtension()) {
TerminalBlockEntity ctrl = getController();
if (ctrl != null && ctrl != this) {
// The controller also needs a fresh scan
ctrl.rescanGroup();
ctrl.startTerminalIfNeeded();
if (ctrl.terminalStarted && screenOpener != null) {
screenOpener.accept(ctrl);
}
return;
}
// Controller not found — fall through and act as standalone
controllerPos = null;
screenGroup = null;
startTerminal();
}
// We are the controller (or standalone)
startTerminalIfNeeded();
if (terminalStarted && screenOpener != null) {
screenOpener.accept(this);
}
}
// ==================== TERMINAL LIFECYCLE ====================
private void startTerminalIfNeeded() {
if (terminalStarted) return;
try {
terminal = new NativeTerminal(cols, rows, fontSize, "", "");
terminalStarted = true;
int[] dims = terminal.getDimensions();
if (dims == null || dims.length < 4 || dims[0] <= 0 || dims[1] <= 0) {
throw new RuntimeException("Invalid terminal dimensions");
}
pixelWidth = dims[0];
pixelHeight = dims[1];
pixelBuffer = ByteBuffer.allocateDirect(pixelWidth * pixelHeight * 4);
} catch (Exception e) {
if (terminal != null) terminal.close();
terminal = null;
terminalStarted = false;
pixelBuffer = null;
}
}
private void stopTerminal() {
if (terminal != null) {
terminal.close();
terminal = null;
}
terminalStarted = false;
pixelBuffer = null;
pixelWidth = 0;
pixelHeight = 0;
}
/**
* Resize a running terminal to new dimensions.
*/
private void resizeTerminal(int newCols, int newRows) {
if (!terminalStarted || terminal == null) {
cols = newCols;
rows = newRows;
return;
}
if (newCols == cols && newRows == rows) return;
cols = newCols;
rows = newRows;
terminal.resize(cols, rows);
int[] dims = terminal.getDimensions();
if (dims != null && dims.length >= 2 && dims[0] > 0 && dims[1] > 0) {
pixelWidth = dims[0];
pixelHeight = dims[1];
pixelBuffer = ByteBuffer.allocateDirect(pixelWidth * pixelHeight * 4);
}
}
// ==================== MULTI-BLOCK GROUP ====================
/**
* Scan for adjacent terminal blocks and form/reform a ScreenGroup.
* Scan for adjacent terminal blocks and form/update the group.
* Safe to call multiple times — idempotent.
* Called when a neighboring terminal block is placed or removed.
*/
public void rescanGroup() {
if (level == null) return;
// Reset lazy scan flag so the client re-evaluates
clientGroupScanned = false;
clientTickCount = 0;
ScreenGroup group = ScreenGroup.scan(level, getBlockPos());
if (group != null) {
// Part of a multi-block group
if (group != null && group.getMembers().size() > 1) {
this.screenGroup = group;
BlockPos ctrlPos = group.getControllerPos();
int newCols = group.totalCols(COLS_PER_BLOCK);
int newRows = group.totalRows(ROWS_PER_BLOCK);
if (ctrlPos.equals(getBlockPos())) {
// This IS the controller
// I am the controller
this.controllerPos = null;
// Resize terminal for the full group
int newCols = group.totalCols(COLS_PER_BLOCK);
int newRows = group.totalRows(ROWS_PER_BLOCK);
if (terminalStarted && (newCols != cols || newRows != rows)) {
cols = newCols;
rows = newRows;
if (terminal != null) {
terminal.resize(cols, rows);
int[] dims = terminal.getDimensions();
if (dims != null && dims.length >= 2) {
pixelWidth = dims[0];
pixelHeight = dims[1];
pixelBuffer = ByteBuffer.allocateDirect(pixelWidth * pixelHeight * 4);
}
}
} else {
cols = newCols;
rows = newRows;
}
resizeTerminal(newCols, newRows);
} else {
// This is an extension — point to the controller
// I am an extension
this.controllerPos = ctrlPos;
// If I had a rogue terminal running (started before group formed), kill it
if (terminalStarted) {
stopTerminal();
}
}
// Propagate group to all members and resize the controller if needed
int newCols = group.totalCols(COLS_PER_BLOCK);
int newRows = group.totalRows(ROWS_PER_BLOCK);
// Propagate to all other members
for (BlockPos memberPos : group.getMembers()) {
if (memberPos.equals(getBlockPos())) continue;
BlockEntity be = level.getBlockEntity(memberPos);
if (be instanceof TerminalBlockEntity memberBE) {
memberBE.screenGroup = group;
boolean isCtrl = ctrlPos.equals(memberPos);
memberBE.controllerPos = isCtrl ? null : ctrlPos;
if (be instanceof TerminalBlockEntity member) {
member.screenGroup = group;
boolean memberIsCtrl = ctrlPos.equals(memberPos);
member.controllerPos = memberIsCtrl ? null : ctrlPos;
// If this member is the controller and has a running terminal,
// resize it to match the new group dimensions
if (isCtrl && memberBE.terminalStarted && memberBE.terminal != null) {
if (memberBE.cols != newCols || memberBE.rows != newRows) {
memberBE.cols = newCols;
memberBE.rows = newRows;
memberBE.terminal.resize(newCols, newRows);
int[] dims = memberBE.terminal.getDimensions();
if (dims != null && dims.length >= 2) {
memberBE.pixelWidth = dims[0];
memberBE.pixelHeight = dims[1];
memberBE.pixelBuffer = ByteBuffer.allocateDirect(dims[0] * dims[1] * 4);
}
}
} else if (isCtrl) {
memberBE.cols = newCols;
memberBE.rows = newRows;
if (memberIsCtrl) {
member.resizeTerminal(newCols, newRows);
} else if (member.terminalStarted) {
// Extension with rogue terminal — kill it
member.stopTerminal();
}
}
}
} else {
// Single block — reset to defaults
// Single block — reset to standalone
this.screenGroup = null;
this.controllerPos = null;
this.cols = 80;
this.rows = 24;
if (!terminalStarted) {
this.cols = DEFAULT_COLS;
this.rows = DEFAULT_ROWS;
}
}
}
/**
* Get the controller block entity.
* Returns self if standalone or if this IS the controller.
* Get the controller block entity (self if this IS the controller, or look up).
* Returns null-safe: if controller was destroyed, resets to standalone.
*/
@Nullable
public TerminalBlockEntity getController() {
@@ -172,89 +224,61 @@
if (be instanceof TerminalBlockEntity controller) {
return controller;
}
// Controller was destroyed — become standalone
controllerPos = null;
screenGroup = null;
return this;
}
/**
* Get the screen group (if part of a multi-block screen).
*/
@Nullable
public ScreenGroup getScreenGroup() {
return screenGroup;
}
@Nullable public ScreenGroup getScreenGroup() { return screenGroup; }
public boolean isExtension() { return controllerPos != null; }
// ==================== BLOCK REMOVAL ====================
/**
* Called when this block is broken. Stops terminal, notifies neighbors.
* Check if this block is an extension (not the controller).
*/
public void onBlockRemoved() {
public boolean isExtension() {
return controllerPos != null;
}
stopTerminal();
private void startTerminal() {
if (terminal != null) return;
try {
terminal = new NativeTerminal(cols, rows, fontSize, "", "");
terminalStarted = true;
int[] dims = terminal.getDimensions();
if (dims == null || dims.length < 4) {
throw new RuntimeException("Failed to get terminal dimensions");
}
pixelWidth = dims[0];
pixelHeight = dims[1];
if (pixelWidth <= 0 || pixelHeight <= 0) {
throw new RuntimeException("Invalid terminal dimensions");
}
pixelBuffer = ByteBuffer.allocateDirect(pixelWidth * pixelHeight * 4);
} catch (Exception e) {
if (terminal != null) terminal.close();
terminal = null;
terminalStarted = false;
pixelBuffer = null;
}
}
// Capture and clear group state BEFORE notifying neighbors
ScreenGroup group = this.screenGroup;
this.screenGroup = null;
this.controllerPos = null;
private void stopTerminal() {
if (terminal != null) {
terminal.close();
terminal = null;
}
// Notify remaining group members to reform without this block
if (level != null && group != null) {
for (BlockPos memberPos : group.getMembers()) {
terminalStarted = false;
pixelBuffer = null;
}
public void onBlockRemoved() {
stopTerminal();
// Notify neighbors to rescan their groups
if (level != null && screenGroup != null) {
for (BlockPos memberPos : screenGroup.getMembers()) {
if (memberPos.equals(getBlockPos())) continue;
BlockEntity be = level.getBlockEntity(memberPos);
if (be instanceof TerminalBlockEntity memberBE) {
memberBE.rescanGroup();
if (be instanceof TerminalBlockEntity member) {
// Clear stale group references first
member.screenGroup = null;
member.controllerPos = null;
member.clientGroupScanned = false;
// Then rescan to form new group(s) without the broken block
member.rescanGroup();
}
}
}
}
// Client tick counter for lazy group detection
// ==================== CLIENT TICK ====================
private int clientTickCount = 0;
private boolean clientGroupScanned = false;
public static void clientTick(Level level, BlockPos pos, BlockState state, TerminalBlockEntity be) {
be.clientTickCount++;
// Lazy client-side group detection: onPlace only fires on the server,
// so the client needs to discover groups itself. Rescan periodically
// until a stable group is found, then stop scanning.
// Lazy client-side group detection for RENDERING only.
// onPlace fires on server only, so the client discovers groups here.
// This does NOT start terminals — that only happens on player interaction.
if (!be.clientGroupScanned && be.clientTickCount % 10 == 0) {
be.rescanGroup();
// Stop scanning once we've had a few chances to find neighbors
if (be.clientTickCount > 60) {
be.clientGroupScanned = true;
}
}
// Only the controller runs the terminal tick
// Only the controller runs the terminal
if (be.terminal == null || !be.terminalStarted) return;
boolean alive = be.terminal.pollPty();
if (!alive) {
@@ -270,7 +294,7 @@
}
}
// ==================== ACCESSORS ====================
// --- Accessors ---
@Nullable public ByteBuffer getPixelBuffer() { return pixelBuffer; }
public int getPixelWidth() { return pixelWidth; }
@@ -286,7 +310,7 @@
screenOpener = opener;
}
// --- Serialization ---
// ==================== SERIALIZATION ====================
@Override
protected void saveAdditional(CompoundTag tag) {
@@ -299,10 +323,6 @@
}
}
/**
* Include custom data in the update tag sent to clients.
* Without this, clients would not receive Cols/Rows/FontSize on chunk load.
*/
@Override
public CompoundTag getUpdateTag() {
CompoundTag tag = super.getUpdateTag();
fabric/src/main/java/io/fangorn/alacrittymc/fabric/test/TerminalGameTest.java +273 −39
@@ -485,44 +485,7 @@
});
}
// ==================== MULTI-BLOCK WIRING (block entity integration) ====================
/** Placing extension next to an already-running controller resizes it. */
@GameTest(template = EMPTY_STRUCTURE, timeoutTicks = 40)
public void extensionResizesRunningController(GameTestHelper h) {
BlockPos a = new BlockPos(1, 1, 1);
BlockPos b = new BlockPos(2, 1, 1);
// Place block A first (simulating player placing and starting terminal)
h.setBlock(a, terminalState(Direction.NORTH));
h.runAfterDelay(3, () -> {
TerminalBlockEntity beA = assertTerminalEntity(h, a);
// Verify A starts with default single-block dimensions
if (beA.getCols() != 80) throw new AssertionError("A should start at 80 cols");
if (beA.getRows() != 24) throw new AssertionError("A should start at 24 rows");
// Now place block B next to it
h.setBlock(b, terminalState(Direction.NORTH));
h.runAfterDelay(5, () -> {
// Trigger rescan from the NEW block (what onPlace does)
TerminalBlockEntity beB = assertTerminalEntity(h, b);
beB.rescanGroup();
// The controller's dimensions should have been updated
TerminalBlockEntity ctrl = beA.isExtension() ? beB : beA;
// 2 blocks wide × 40 cols/block = 80 cols, 1 block tall × 12 rows/block = 12 rows
if (ctrl.getCols() != 80) {
throw new AssertionError("Controller should have 80 cols after group, got " + ctrl.getCols());
}
if (ctrl.getRows() != 12) {
throw new AssertionError("Controller should have 12 rows after group, got " + ctrl.getRows());
}
h.succeed();
});
});
}
/** rescanGroup() assigns controller and extension roles in a 2x1. */
// Old multi-block wiring tests replaced by comprehensive suite
@GameTest(template = EMPTY_STRUCTURE, timeoutTicks = 40)
public void rescanAssignsControllerAndExtension(GameTestHelper h) {
BlockPos left = new BlockPos(1, 1, 1);
@@ -556,7 +519,7 @@
/** Extension block's getController() returns the controller entity. */
@GameTest(template = EMPTY_STRUCTURE, timeoutTicks = 40)
public void extensionDelegatesToController(GameTestHelper h) {
public void extensionDelegatesToControllerOld(GameTestHelper h) {
BlockPos a = new BlockPos(1, 1, 1);
BlockPos b = new BlockPos(2, 1, 1);
h.setBlock(a, terminalState(Direction.NORTH));
@@ -681,10 +644,281 @@
h.succeed();
});
});
});
}
// ==================== MULTI-BLOCK WIRING ====================
/** Rescan assigns exactly one controller and N-1 extensions in a 2x1. */
@GameTest(template = EMPTY_STRUCTURE, timeoutTicks = 40)
public void rescanRoles2x1(GameTestHelper h) {
BlockPos a = new BlockPos(1, 1, 1), b = new BlockPos(2, 1, 1);
h.setBlock(a, terminalState(Direction.NORTH));
h.setBlock(b, terminalState(Direction.NORTH));
h.runAfterDelay(5, () -> {
assertTerminalEntity(h, a).rescanGroup();
TerminalBlockEntity beA = assertTerminalEntity(h, a);
TerminalBlockEntity beB = assertTerminalEntity(h, b);
int controllers = (beA.isExtension() ? 0 : 1) + (beB.isExtension() ? 0 : 1);
if (controllers != 1) throw new AssertionError("Expected exactly 1 controller, got " + controllers);
h.succeed();
});
}
/** Extension's getController() returns the actual controller entity. */
@GameTest(template = EMPTY_STRUCTURE, timeoutTicks = 40)
public void extensionDelegatesToController(GameTestHelper h) {
BlockPos a = new BlockPos(1, 1, 1), b = new BlockPos(2, 1, 1);
h.setBlock(a, terminalState(Direction.NORTH));
h.setBlock(b, terminalState(Direction.NORTH));
h.runAfterDelay(5, () -> {
assertTerminalEntity(h, a).rescanGroup();
TerminalBlockEntity beA = assertTerminalEntity(h, a);
TerminalBlockEntity beB = assertTerminalEntity(h, b);
TerminalBlockEntity ext = beA.isExtension() ? beA : beB;
TerminalBlockEntity ctrl = beA.isExtension() ? beB : beA;
if (ext.getController() != ctrl) throw new AssertionError("Extension.getController() wrong");
h.succeed();
});
}
/** 2x1 controller has 80 cols, 12 rows. */
@GameTest(template = EMPTY_STRUCTURE, timeoutTicks = 40)
public void dims2x1(GameTestHelper h) {
BlockPos a = new BlockPos(1, 1, 1), b = new BlockPos(2, 1, 1);
h.setBlock(a, terminalState(Direction.NORTH));
h.setBlock(b, terminalState(Direction.NORTH));
h.runAfterDelay(5, () -> {
assertTerminalEntity(h, a).rescanGroup();
TerminalBlockEntity ctrl = findController(h, a, b);
assertDims(ctrl, 80, 12);
h.succeed();
});
}
/** 2x2 = 80 cols, 24 rows. */
@GameTest(template = EMPTY_STRUCTURE, timeoutTicks = 40)
public void dims2x2(GameTestHelper h) {
placeGrid(h, 1, 1, 2, 2, 1, Direction.NORTH);
h.runAfterDelay(5, () -> {
rescanAll(h, 1, 1, 2, 2, 1);
TerminalBlockEntity ctrl = findAnyController(h, 1, 1, 2, 2, 1);
assertDims(ctrl, 80, 24);
h.succeed();
});
}
/** 3x2 = 120 cols, 24 rows. */
@GameTest(template = EMPTY_STRUCTURE, timeoutTicks = 40)
public void dims3x2(GameTestHelper h) {
placeGrid(h, 1, 1, 3, 2, 1, Direction.NORTH);
h.runAfterDelay(5, () -> {
rescanAll(h, 1, 1, 3, 2, 1);
TerminalBlockEntity ctrl = findAnyController(h, 1, 1, 3, 2, 1);
assertDims(ctrl, 120, 24);
h.succeed();
});
}
/** Rescan kills rogue terminal on extension that started before group formed. */
@GameTest(template = EMPTY_STRUCTURE, timeoutTicks = 40)
public void rescanKillsRogueTerminalOnExtension(GameTestHelper h) {
BlockPos a = new BlockPos(1, 1, 1), b = new BlockPos(2, 1, 1);
h.setBlock(a, terminalState(Direction.NORTH));
h.setBlock(b, terminalState(Direction.NORTH));
h.runAfterDelay(5, () -> {
// Before rescan, neither is an extension, so neither has a "rogue" terminal
// But after rescan, exactly one is the extension and should NOT be running
assertTerminalEntity(h, a).rescanGroup();
TerminalBlockEntity beA = assertTerminalEntity(h, a);
TerminalBlockEntity beB = assertTerminalEntity(h, b);
TerminalBlockEntity ext = beA.isExtension() ? beA : beB;
if (ext.isTerminalRunning()) throw new AssertionError("Extension should not have a running terminal");
h.succeed();
});
}
// ==================== BREAKING TESTS ====================
/** Break controller of 2x1 — no crash, extension becomes standalone. */
@GameTest(template = EMPTY_STRUCTURE, timeoutTicks = 60)
public void breakController2x1(GameTestHelper h) {
BlockPos a = new BlockPos(1, 1, 1), b = new BlockPos(2, 1, 1);
h.setBlock(a, terminalState(Direction.NORTH));
h.setBlock(b, terminalState(Direction.NORTH));
h.runAfterDelay(5, () -> {
assertTerminalEntity(h, a).rescanGroup();
TerminalBlockEntity beA = assertTerminalEntity(h, a);
TerminalBlockEntity beB = assertTerminalEntity(h, b);
BlockPos ctrlPos = beA.isExtension() ? b : a;
BlockPos extPos = beA.isExtension() ? a : b;
// Break the controller
h.destroyBlock(ctrlPos);
h.runAfterDelay(5, () -> {
// Extension should become standalone
TerminalBlockEntity survivor = assertTerminalEntity(h, extPos);
if (survivor.isExtension()) throw new AssertionError("Survivor should be standalone, not extension");
if (survivor.getScreenGroup() != null && survivor.getScreenGroup().getMembers().size() > 1)
throw new AssertionError("Survivor should not be in a group");
h.succeed();
});
});
}
/** Break extension of 2x1 — controller survives with default dims. */
@GameTest(template = EMPTY_STRUCTURE, timeoutTicks = 60)
public void breakExtension2x1(GameTestHelper h) {
BlockPos a = new BlockPos(1, 1, 1), b = new BlockPos(2, 1, 1);
h.setBlock(a, terminalState(Direction.NORTH));
h.setBlock(b, terminalState(Direction.NORTH));
h.runAfterDelay(5, () -> {
assertTerminalEntity(h, a).rescanGroup();
TerminalBlockEntity beA = assertTerminalEntity(h, a);
BlockPos ctrlPos = beA.isExtension() ? b : a;
BlockPos extPos = beA.isExtension() ? a : b;
h.destroyBlock(extPos);
h.runAfterDelay(5, () -> {
TerminalBlockEntity ctrl = assertTerminalEntity(h, ctrlPos);
if (ctrl.isExtension()) throw new AssertionError("Controller should remain standalone");
// Should revert to default single-block dims
assertDims(ctrl, 80, 24);
h.succeed();
});
});
}
/** Break middle of 3x1 — two singletons remain, no crash. */
@GameTest(template = EMPTY_STRUCTURE, timeoutTicks = 60)
public void breakMiddle3x1(GameTestHelper h) {
BlockPos a = new BlockPos(1, 1, 1), b = new BlockPos(2, 1, 1), c = new BlockPos(3, 1, 1);
h.setBlock(a, terminalState(Direction.NORTH));
h.setBlock(b, terminalState(Direction.NORTH));
h.setBlock(c, terminalState(Direction.NORTH));
h.runAfterDelay(5, () -> {
assertTerminalEntity(h, a).rescanGroup();
h.destroyBlock(b);
h.runAfterDelay(5, () -> {
TerminalBlockEntity beA = assertTerminalEntity(h, a);
TerminalBlockEntity beC = assertTerminalEntity(h, c);
if (beA.isExtension()) throw new AssertionError("A should be standalone");
if (beC.isExtension()) throw new AssertionError("C should be standalone");
h.succeed();
});
});
}
/** Break all blocks one by one — no crash at any point. */
@GameTest(template = EMPTY_STRUCTURE, timeoutTicks = 80)
public void breakAllOneByOne(GameTestHelper h) {
BlockPos a = new BlockPos(1, 1, 1), b = new BlockPos(2, 1, 1), c = new BlockPos(3, 1, 1);
h.setBlock(a, terminalState(Direction.NORTH));
h.setBlock(b, terminalState(Direction.NORTH));
h.setBlock(c, terminalState(Direction.NORTH));
h.runAfterDelay(5, () -> {
assertTerminalEntity(h, a).rescanGroup();
h.destroyBlock(a);
h.runAfterDelay(3, () -> {
h.destroyBlock(b);
h.runAfterDelay(3, () -> {
h.destroyBlock(c);
h.runAfterDelay(3, () -> {
h.assertBlockNotPresent(AlacrittyMod.TERMINAL_BLOCK.get(), a);
h.assertBlockNotPresent(AlacrittyMod.TERMINAL_BLOCK.get(), b);
h.assertBlockNotPresent(AlacrittyMod.TERMINAL_BLOCK.get(), c);
h.succeed();
});
});
});
});
}
/** Place, form group, break, place again — no crash or stale state. */
@GameTest(template = EMPTY_STRUCTURE, timeoutTicks = 80)
public void placeBreakReplace(GameTestHelper h) {
BlockPos a = new BlockPos(1, 1, 1), b = new BlockPos(2, 1, 1);
h.setBlock(a, terminalState(Direction.NORTH));
h.setBlock(b, terminalState(Direction.NORTH));
h.runAfterDelay(5, () -> {
assertTerminalEntity(h, a).rescanGroup();
// Break B
h.destroyBlock(b);
h.runAfterDelay(5, () -> {
// Re-place B
h.setBlock(b, terminalState(Direction.NORTH));
h.runAfterDelay(5, () -> {
// Rescan — should form group again
assertTerminalEntity(h, a).rescanGroup();
TerminalBlockEntity beA = assertTerminalEntity(h, a);
TerminalBlockEntity beB = assertTerminalEntity(h, b);
if (beA.getScreenGroup() == null || beB.getScreenGroup() == null) {
throw new AssertionError("Group should reform after re-placing");
}
h.succeed();
});
});
});
}
/** getController() returns self when controller block was destroyed. */
@GameTest(template = EMPTY_STRUCTURE, timeoutTicks = 60)
public void getControllerAfterDestruction(GameTestHelper h) {
BlockPos a = new BlockPos(1, 1, 1), b = new BlockPos(2, 1, 1);
h.setBlock(a, terminalState(Direction.NORTH));
h.setBlock(b, terminalState(Direction.NORTH));
h.runAfterDelay(5, () -> {
assertTerminalEntity(h, a).rescanGroup();
TerminalBlockEntity beA = assertTerminalEntity(h, a);
TerminalBlockEntity beB = assertTerminalEntity(h, b);
BlockPos ctrlPos = beA.isExtension() ? b : a;
BlockPos extPos = beA.isExtension() ? a : b;
h.destroyBlock(ctrlPos);
h.runAfterDelay(5, () -> {
TerminalBlockEntity survivor = assertTerminalEntity(h, extPos);
// getController should return self (not crash)
TerminalBlockEntity ctrl = survivor.getController();
if (ctrl != survivor) throw new AssertionError("Should return self when controller destroyed");
h.succeed();
});
});
}
// ==================== HELPERS ====================
private void placeGrid(GameTestHelper h, int x0, int y0, int w, int height, int z, Direction facing) {
for (int x = x0; x < x0 + w; x++)
for (int y = y0; y < y0 + height; y++)
h.setBlock(new BlockPos(x, y, z), terminalState(facing));
}
private void rescanAll(GameTestHelper h, int x0, int y0, int w, int height, int z) {
for (int x = x0; x < x0 + w; x++)
for (int y = y0; y < y0 + height; y++)
assertTerminalEntity(h, new BlockPos(x, y, z)).rescanGroup();
}
private TerminalBlockEntity findController(GameTestHelper h, BlockPos a, BlockPos b) {
TerminalBlockEntity beA = assertTerminalEntity(h, a);
return beA.isExtension() ? assertTerminalEntity(h, b) : beA;
}
private TerminalBlockEntity findAnyController(GameTestHelper h, int x0, int y0, int w, int height, int z) {
for (int x = x0; x < x0 + w; x++)
for (int y = y0; y < y0 + height; y++) {
TerminalBlockEntity be = assertTerminalEntity(h, new BlockPos(x, y, z));
if (!be.isExtension()) return be;
}
throw new AssertionError("No controller found in grid");
}
private static void assertDims(TerminalBlockEntity ctrl, int expectedCols, int expectedRows) {
if (ctrl.getCols() != expectedCols)
throw new AssertionError("Cols=" + ctrl.getCols() + ", expected " + expectedCols);
if (ctrl.getRows() != expectedRows)
throw new AssertionError("Rows=" + ctrl.getRows() + ", expected " + expectedRows);
}
private static BlockState terminalState(Direction facing) {
return AlacrittyMod.TERMINAL_BLOCK.get().defaultBlockState()