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();