@@ -1,6 +1,7 @@
package io.fangorn.alacrittymc.block;
import io.fangorn.alacrittymc.AlacrittyMod;
import io.fangorn.alacrittymc.config.AlacrittyConfig;
import io.fangorn.alacrittymc.nativelib.NativeTerminal;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
@@ -11,20 +12,14 @@
import org.jetbrains.annotations.Nullable;
import java.nio.ByteBuffer;
import java.util.concurrent.ConcurrentHashMap;
/**
* Block entity for the terminal block.
*
* Multi-block design:
* - 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)
* Terminals persist across chunk unloads via a global registry keyed by
* block position. When a chunk unloads, the terminal moves to the registry.
* When the chunk reloads, the BE reclaims its terminal from the registry.
*
* 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 {
public static final int COLS_PER_BLOCK = 40;
@@ -32,6 +27,12 @@
private static final int DEFAULT_COLS = 80;
private static final int DEFAULT_ROWS = 24;
// Global registry of running terminals that survive chunk unload.
// Key = block position (long), Value = running NativeTerminal.
private static final ConcurrentHashMap<Long, NativeTerminal> TERMINAL_REGISTRY = new ConcurrentHashMap<>();
// Track how many active terminals exist (for per-player limits)
private static int activeTerminalCount = 0;
private int cols = DEFAULT_COLS;
private int rows = DEFAULT_ROWS;
private float fontSize = 14.0f;
@@ -43,10 +44,8 @@
private boolean textureNeedsUpdate = false;
private boolean terminalStarted = false;
@Nullable private BlockPos controllerPos;
// Multi-block state
@Nullable private BlockPos controllerPos; // null = I am the controller (or standalone)
@Nullable private ScreenGroup screenGroup;
// Client-side lazy group scanning
private int clientTickCount = 0;
private boolean clientGroupScanned = false;
@@ -60,23 +59,14 @@
// ==================== PLAYER INTERACTION ====================
/**
* Called when a player right-clicks this terminal block's screen face.
* Always rescans group first to ensure correct role assignment,
* then delegates to the controller if this is an extension.
*/
public void onPlayerInteract(Player player) {
if (level == null || !level.isClientSide()) return;
// ALWAYS rescan before starting anything — this ensures we know
// our role (controller vs extension) before creating a terminal
rescanGroup();
// 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) {
@@ -84,12 +74,10 @@
}
return;
}
// Controller not found — fall through and act as standalone
controllerPos = null;
screenGroup = null;
}
// We are the controller (or standalone)
startTerminalIfNeeded();
if (terminalStarted && screenOpener != null) {
screenOpener.accept(this);
@@ -100,9 +88,36 @@
private void startTerminalIfNeeded() {
if (terminalStarted) return;
// Check terminal count limit
int maxTerminals = AlacrittyConfig.getInstance().getMaxTerminalsPerPlayer();
if (activeTerminalCount >= maxTerminals) {
System.out.println("[AlacrittyMC] Terminal limit reached (" + maxTerminals + ")");
return;
}
// Check if there's a surviving terminal in the registry (chunk was reloaded)
long posKey = getBlockPos().asLong();
NativeTerminal surviving = TERMINAL_REGISTRY.remove(posKey);
if (surviving != null && !surviving.isClosed() && surviving.isAlive()) {
terminal = surviving;
terminalStarted = true;
int[] dims = terminal.getDimensions();
if (dims != null && dims.length >= 4 && dims[0] > 0 && dims[1] > 0) {
pixelWidth = dims[0];
pixelHeight = dims[1];
pixelBuffer = ByteBuffer.allocateDirect(pixelWidth * pixelHeight * 4);
return;
}
// Surviving terminal is broken, fall through to create new one
terminal.close();
terminal = null;
}
try {
terminal = new NativeTerminal(cols, rows, fontSize, "", "");
terminalStarted = true;
activeTerminalCount++;
int[] dims = terminal.getDimensions();
if (dims == null || dims.length < 4 || dims[0] <= 0 || dims[1] <= 0) {
throw new RuntimeException("Invalid terminal dimensions");
@@ -122,6 +137,7 @@
if (terminal != null) {
terminal.close();
terminal = null;
activeTerminalCount = Math.max(0, activeTerminalCount - 1);
}
terminalStarted = false;
pixelBuffer = null;
@@ -130,8 +146,20 @@
}
/**
* Resize a running terminal to new dimensions.
* Park the terminal in the global registry (chunk unload).
* The terminal keeps running; the BE can reclaim it later.
*/
private void parkTerminal() {
if (terminal != null && !terminal.isClosed()) {
TERMINAL_REGISTRY.put(getBlockPos().asLong(), terminal);
terminal = null; // Don't close — it's parked
}
terminalStarted = false;
pixelBuffer = null;
pixelWidth = 0;
pixelHeight = 0;
}
private void resizeTerminal(int newCols, int newRows) {
if (!terminalStarted || terminal == null) {
cols = newCols;
@@ -139,7 +167,6 @@
return;
}
if (newCols == cols && newRows == rows) return;
cols = newCols;
rows = newRows;
terminal.resize(cols, rows);
@@ -153,16 +180,13 @@
// ==================== MULTI-BLOCK GROUP ====================
/**
* Scan for adjacent terminal blocks and form/update the group.
* Safe to call multiple times — idempotent.
*/
public void rescanGroup() {
if (level == null) return;
clientGroupScanned = false;
clientTickCount = 0;
ScreenGroup group = ScreenGroup.scan(level, getBlockPos());
int maxGroupSize = AlacrittyConfig.getInstance().getMaxTerminalsPerPlayer(); // reuse as group limit
if (group != null && group.getMembers().size() > 1) {
this.screenGroup = group;
@@ -171,19 +195,13 @@
int newRows = group.totalRows(ROWS_PER_BLOCK);
if (ctrlPos.equals(getBlockPos())) {
// I am the controller
this.controllerPos = null;
resizeTerminal(newCols, newRows);
} else {
// I am an extension
this.controllerPos = ctrlPos;
if (terminalStarted) stopTerminal();
// If I had a rogue terminal running (started before group formed), kill it
if (terminalStarted) {
stopTerminal();
}
}
// Propagate to all other members
for (BlockPos memberPos : group.getMembers()) {
if (memberPos.equals(getBlockPos())) continue;
BlockEntity be = level.getBlockEntity(memberPos);
@@ -191,17 +209,14 @@
member.screenGroup = group;
boolean memberIsCtrl = ctrlPos.equals(memberPos);
member.controllerPos = memberIsCtrl ? null : ctrlPos;
if (memberIsCtrl) {
member.resizeTerminal(newCols, newRows);
} else if (member.terminalStarted) {
// Extension with rogue terminal — kill it
member.stopTerminal();
}
}
}
} else {
// Single block — reset to standalone
this.screenGroup = null;
this.controllerPos = null;
if (!terminalStarted) {
@@ -211,11 +226,6 @@
}
}
/**
* Get the controller block entity.
* Returns self if standalone or if this IS the controller.
* Returns null-safe: if controller was destroyed, resets to standalone.
*/
@Nullable
public TerminalBlockEntity getController() {
if (controllerPos == null) return this;
@@ -224,7 +234,6 @@
if (be instanceof TerminalBlockEntity controller) {
return controller;
}
// Controller was destroyed — become standalone
controllerPos = null;
screenGroup = null;
return this;
@@ -235,43 +244,31 @@
// ==================== BLOCK REMOVAL ====================
/**
* Called when this block is broken. Stops terminal, notifies neighbors.
* All operations are wrapped in try-catch to prevent crashes from
* cascading group updates during world modification.
*/
public void onBlockRemoved() {
try {
stopTerminal();
stopTerminal(); // Actually kill the PTY (block was broken by player)
TERMINAL_REGISTRY.remove(getBlockPos().asLong()); // Clean registry too
} catch (Exception e) {
System.err.println("[AlacrittyMC] Error stopping terminal: " + e);
}
// Capture and clear group state BEFORE notifying neighbors
ScreenGroup group = this.screenGroup;
this.screenGroup = null;
this.controllerPos = null;
// Notify remaining group members — clear their stale refs and let
// the lazy client tick rescan later (don't do cascading rescans now,
// as the world state may be inconsistent during block breaking)
if (level != null && group != null) {
for (BlockPos memberPos : group.getMembers()) {
if (memberPos.equals(getBlockPos())) continue;
try {
BlockEntity be = level.getBlockEntity(memberPos);
if (be instanceof TerminalBlockEntity member) {
// Clear stale refs — lazy tick will rescan
member.screenGroup = null;
member.controllerPos = null;
member.clientGroupScanned = false;
member.clientTickCount = 0;
// If this member was running a terminal (it was
// the controller), the terminal is now orphaned.
// Don't stop it here — let the rescan handle it.
}
} catch (Exception e) {
System.err.println("[AlacrittyMC] Error notifying neighbor at " + memberPos + ": " + e);
System.err.println("[AlacrittyMC] Error notifying neighbor: " + e);
}
}
}
@@ -279,12 +276,12 @@
// ==================== CLIENT TICK ====================
private long tickPollNs = 0, tickRenderNs = 0;
private int tickCount = 0;
public static void clientTick(Level level, BlockPos pos, BlockState state, TerminalBlockEntity be) {
be.clientTickCount++;
// 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();
if (be.clientTickCount > 60) {
@@ -292,16 +289,20 @@
}
}
// Only the controller runs the terminal tick
if (be.terminal == null || !be.terminalStarted) return;
long t0 = System.nanoTime();
boolean alive = be.terminal.pollPty();
long t1 = System.nanoTime();
if (!alive) {
// Shell exited (user ran `exit` or process died).
// Return to unstarted state — block shows Matrix rain again,
// ready for another right-click to start a new shell.
be.stopTerminal();
return;
}
boolean dirty = false;
if (be.pixelBuffer != null) {
be.pixelBuffer.rewind();
@@ -312,7 +313,6 @@
}
long t2 = System.nanoTime();
// Profiling: print every 100 ticks
be.tickPollNs += (t1 - t0);
be.tickRenderNs += (t2 - t1);
be.tickCount++;
@@ -324,9 +324,5 @@
be.tickRenderNs = 0;
}
}
// Profiling accumulators
private long tickPollNs = 0, tickRenderNs = 0;
private int tickCount = 0;
// ==================== ACCESSORS ====================
@@ -345,6 +341,12 @@
screenOpener = opener;
}
/** Clean up all parked terminals (call on game shutdown). */
public static void shutdownAll() {
TERMINAL_REGISTRY.values().forEach(NativeTerminal::close);
TERMINAL_REGISTRY.clear();
}
// ==================== SERIALIZATION ====================
@Override
@@ -378,7 +380,8 @@
@Override
public void setRemoved() {
stopTerminal();
// Chunk unload — park the terminal, don't kill it
parkTerminal();
super.setRemoved();
}
}