diff --git a/.gitignore b/.gitignore
index 852ca6d..773b955 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,6 @@ luamap-run/
ide-plugin/.intellijPlatform/
# managed IDE runtime artifacts
.luamap/
+
+# JVM crash logs
+hs_err_pid*.log
diff --git a/ide-plugin/README.md b/ide-plugin/README.md
index a62bf84..f4562ce 100644
--- a/ide-plugin/README.md
+++ b/ide-plugin/README.md
@@ -12,10 +12,13 @@ the protocol documented in `../docs/luabridge.md`.
- "LuaMap Script" run configuration (host/port/script) that sends the script
to LuaBridge (`run` op) and prints script output in the Run console.
- Gutter run marker on `.luamap` files (wires into the run configuration).
-- "LuaMap" tool window stub for the block-preview panel (to be driven by
- `world.getblock`/`world.fill` queries over LuaBridge).
-- `LuaBridgeClient` — synchronous newline-delimited-JSON socket client for the
- bridge protocol.
+- Live "LuaMap" tool window: host/port connect controls, connection badge
+ (Connected/Connecting/Error/Disconnected), auto-reconnect, periodic
+ status polling and NPC inspector (`npc.list()` via `eval`), block query
+ (`world.getblock`), script list + run/reload, and an eval console — all
+ socket I/O off the EDT via `LuaBridgeSession`'s background executor.
+- `LuaBridgeClient`/`LuaBridgeSession` — synchronous NDJSON socket client
+ plus a lifecycle/polling controller for the bridge protocol.
## Build
@@ -56,5 +59,15 @@ use the gutter/run button or a "LuaMap Script" run config pointed at
- True debugger UI (breakpoints/step-through) — protocol has the `eval`/`run`
shape to hang a debug session on, but no frame model exists in the mod.
-- Block preview rendering (tool window is a placeholder panel).
+- Block preview *rendering* (the tool window queries blocks via
+ `world.getblock` but doesn't draw a region map).
- Error squiggles mapping LuaError line numbers back into the editor.
+
+## Tests
+
+`./gradlew :ide-plugin:unitTest` — loopback integration tests: a mock NDJSON
+bridge server exercises connect/disconnect/reconnect, status transitions,
+auto-reconnect, NPC + block responses, and protocol framing. The platform
+`test` task is disabled (its IDE runtime harness doesn't work on the unified
+2025.3 distribution); `unitTest` runs on a plain JVM and is wired into
+`check`.
diff --git a/ide-plugin/build.gradle b/ide-plugin/build.gradle
index 413ce2f..c6b3af9 100644
--- a/ide-plugin/build.gradle
+++ b/ide-plugin/build.gradle
@@ -33,6 +33,33 @@ dependencies {
instrumentationTools()
pluginVerifier()
}
+
+ // Gson ships on the IDE classpath at runtime; declare it for tests.
+ testImplementation 'com.google.code.gson:gson:2.11.0'
+ testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+}
+
+// The platform's `test` task runs inside the IDE runtime (PathClassLoader,
+// coroutines javaagent) — broken on the unified 2025.3 distro and unneeded
+// for our pure-JVM bridge/session unit tests. Disable it and run them on a
+// plain JVM via `unitTest` instead (wired into `check`).
+test {
+ enabled = false
+}
+
+tasks.register('unitTest', Test) {
+ useJUnitPlatform()
+ testClassesDirs = sourceSets.test.output.classesDirs
+ classpath = sourceSets.test.runtimeClasspath
+ javaLauncher = javaToolchains.launcherFor(java.toolchain)
+ testLogging {
+ events 'passed', 'failed', 'skipped'
+ }
+}
+
+check {
+ dependsOn tasks.named('unitTest')
}
// Boots the IDE headless to index settings UI — the plugin has no settings
diff --git a/ide-plugin/src/main/java/org/communitypoke/luamap/idea/bridge/LuaBridgeSession.java b/ide-plugin/src/main/java/org/communitypoke/luamap/idea/bridge/LuaBridgeSession.java
new file mode 100644
index 0000000..8c2d3e4
--- /dev/null
+++ b/ide-plugin/src/main/java/org/communitypoke/luamap/idea/bridge/LuaBridgeSession.java
@@ -0,0 +1,441 @@
+package org.communitypoke.luamap.idea.bridge;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+
+/**
+ * Connection lifecycle + polling controller for the LuaBridge tool window.
+ *
+ *
IntelliJ-free by design: every socket operation runs on a single
+ * background daemon thread, and state changes are delivered to a
+ * {@link Listener} through a user-supplied {@link Consumer} dispatcher (the
+ * Swing layer passes {@code SwingUtilities::invokeLater}, tests pass a
+ * synchronous dispatcher). The UI never does socket I/O and the controller
+ * never does UI.
+ *
+ *
Lifecycle: {@link #connect(String, int)} is idempotent while connected;
+ * {@link #disconnect()} stops polling and closes the socket;
+ * {@link #close()} additionally terminates the executor (called on dispose).
+ * With auto-reconnect enabled a failed poll schedules one reconnect attempt
+ * per {@link #RECONNECT_DELAY_MS} until it succeeds or the user disconnects.
+ */
+public final class LuaBridgeSession implements AutoCloseable {
+
+ public enum State {DISCONNECTED, CONNECTING, CONNECTED, ERROR}
+
+ /** One NPC row, parsed from {@link #NPC_LIST_LUA}. */
+ public record NpcInfo(String name, double x, double y, double z) {
+ }
+
+ /** Snapshot of one {@code status} reply. */
+ public record StatusInfo(String raw, int scriptCount, int npcCount, String world) {
+ }
+
+ public interface Listener {
+ /** Invoked via the dispatcher — on the EDT for the Swing layer. */
+ void onStateChanged(State state, String detail);
+ }
+
+ /**
+ * Lua snippet that serializes {@code npc.list()} into one
+ * {@code name,x,y,z} CSV line per NPC (LuaJ has no JSON module).
+ */
+ public static final String NPC_LIST_LUA =
+ "local out = {}\n"
+ + "for n,p in pairs(npc.list()) do\n"
+ + " out[#out+1] = string.format(\"%s,%.2f,%.2f,%.2f\", n, p.x, p.y, p.z)\n"
+ + "end\n"
+ + "table.sort(out)\n"
+ + "return table.concat(out, \"\\n\")\n";
+
+ /** Interval for automatic status + NPC refresh while connected. */
+ public static final long POLL_INTERVAL_MS = 2_000;
+ /** Delay before an auto-reconnect attempt after a connection drop. */
+ public static final long RECONNECT_DELAY_MS = 1_500;
+
+ private final ScheduledExecutorService executor;
+ private final Listener listener;
+ private final Consumer dispatcher;
+
+ private volatile State state = State.DISCONNECTED;
+ private volatile String detail = "not connected";
+ private volatile LuaBridgeClient client;
+ private volatile ScheduledFuture> pollTask;
+ private volatile boolean autoReconnect = true;
+ private volatile String host = "127.0.0.1";
+ private volatile int port = LuaBridgeClient.DEFAULT_PORT;
+ /** Optional observer invoked (via dispatcher) after each successful poll. */
+ private volatile Consumer pollListener;
+
+ public LuaBridgeSession(Listener listener, Consumer dispatcher) {
+ this(listener, dispatcher, defaultExecutor());
+ }
+
+ /** Test seam: lets tests inject an executor they control. */
+ public LuaBridgeSession(Listener listener, Consumer dispatcher,
+ ScheduledExecutorService executor) {
+ this.listener = Objects.requireNonNull(listener);
+ this.dispatcher = Objects.requireNonNull(dispatcher);
+ this.executor = executor;
+ }
+
+ private static ScheduledExecutorService defaultExecutor() {
+ ThreadFactory tf = r -> {
+ Thread t = new Thread(r, "luamap-bridge-session");
+ t.setDaemon(true);
+ return t;
+ };
+ return Executors.newSingleThreadScheduledExecutor(tf);
+ }
+
+ public State state() {
+ return state;
+ }
+
+ public String detail() {
+ return detail;
+ }
+
+ public String host() {
+ return host;
+ }
+
+ public int port() {
+ return port;
+ }
+
+ public boolean autoReconnect() {
+ return autoReconnect;
+ }
+
+ public void setAutoReconnect(boolean enabled) {
+ autoReconnect = enabled;
+ }
+
+ /** Register a listener called with the latest StatusInfo on every poll. */
+ public void setPollListener(Consumer listener) {
+ this.pollListener = listener;
+ }
+
+ public boolean isConnected() {
+ return state == State.CONNECTED;
+ }
+
+ /** Connect (or reconnect to new endpoint). Any failure lands in ERROR. */
+ public void connect(String host, int port) {
+ this.host = host;
+ this.port = port;
+ submit(() -> doConnect());
+ }
+
+ /** Manual reconnect against the last used endpoint. */
+ public void reconnect() {
+ submit(this::doConnect);
+ }
+
+ /** User-initiated disconnect: no auto-reconnect afterwards. */
+ public void disconnect() {
+ submit(() -> {
+ stopPolling();
+ closeClient();
+ setState(State.DISCONNECTED, "disconnected");
+ });
+ }
+
+ /**
+ * Run one request on the session thread. {@code handler} receives the
+ * open client; a failure puts the session in ERROR and starts
+ * auto-reconnect. Returns via the executor — never call from EDT.
+ */
+ public void request(SessionRequest req) {
+ submit(() -> {
+ LuaBridgeClient c = client;
+ if (c == null) {
+ req.onError("not connected");
+ return;
+ }
+ try {
+ req.run(c);
+ } catch (IOException e) {
+ onFailure("request failed: " + e.getMessage());
+ req.onError(e.getMessage());
+ }
+ });
+ }
+
+ /** Poll {@code status} + the NPC table; safe to call when disconnected. */
+ public void refresh(Consumer onStatus, Consumer> onNpcs) {
+ request(new SessionRequest() {
+ @Override
+ public void run(LuaBridgeClient c) throws IOException {
+ StatusInfo st = parseStatus(c.status());
+ if (onStatus != null) {
+ post(() -> onStatus.accept(st));
+ }
+ if (onNpcs != null) {
+ List npcs = parseNpcs(c.eval(NPC_LIST_LUA));
+ post(() -> onNpcs.accept(npcs));
+ }
+ }
+
+ @Override
+ public void onError(String message) {
+ // state/detail already updated by onFailure
+ }
+ });
+ }
+
+ /** Convenience: eval {@code world.getblock(x, y, z)} for the UI. */
+ public void queryBlock(int x, int y, int z, Consumer onResult) {
+ request(new SessionRequest() {
+ @Override
+ public void run(LuaBridgeClient c) throws IOException {
+ LuaBridgeClient.Reply r =
+ c.eval("return tostring(world.getblock(" + x + "," + y + "," + z + "))");
+ post(() -> onResult.accept(replyText(r)));
+ }
+
+ @Override
+ public void onError(String message) {
+ post(() -> onResult.accept("error: " + message));
+ }
+ });
+ }
+
+ /** Convenience: run a named script, reporting output/result. */
+ public void runScript(String name, Consumer onResult) {
+ request(new SessionRequest() {
+ @Override
+ public void run(LuaBridgeClient c) throws IOException {
+ String text = replyText(c.run(name));
+ post(() -> onResult.accept(text));
+ }
+
+ @Override
+ public void onError(String message) {
+ post(() -> onResult.accept("error: " + message));
+ }
+ });
+ }
+
+ /** Convenience: fetch the script list ({@code list} op). */
+ public void listScripts(Consumer> onResult) {
+ request(new SessionRequest() {
+ @Override
+ public void run(LuaBridgeClient c) throws IOException {
+ LuaBridgeClient.Reply r = c.call("list", null, null);
+ List names = new ArrayList<>();
+ if (r.ok() && r.result() != null) {
+ for (String line : r.result().split("\n")) {
+ if (!line.isBlank()) {
+ names.add(line.trim());
+ }
+ }
+ }
+ post(() -> onResult.accept(names));
+ }
+
+ @Override
+ public void onError(String message) {
+ post(() -> onResult.accept(List.of()));
+ }
+ });
+ }
+
+ /** Convenience: eval arbitrary code, reporting output/result. */
+ public void eval(String code, Consumer onResult) {
+ request(new SessionRequest() {
+ @Override
+ public void run(LuaBridgeClient c) throws IOException {
+ String text = replyText(c.eval(code));
+ post(() -> onResult.accept(text));
+ }
+
+ @Override
+ public void onError(String message) {
+ post(() -> onResult.accept("error: " + message));
+ }
+ });
+ }
+
+ private void doConnect() {
+ stopPolling();
+ closeClient();
+ setState(State.CONNECTING, "connecting to " + host + ":" + port);
+ try {
+ client = new LuaBridgeClient(host, port);
+ StatusInfo st = parseStatus(client.status());
+ setState(State.CONNECTED, st.raw());
+ pollTask = executor.scheduleWithFixedDelay(() -> {
+ try {
+ pollOnce();
+ } catch (Throwable ignored) {
+ // pollOnce already routes failures via onFailure
+ }
+ }, POLL_INTERVAL_MS, POLL_INTERVAL_MS, TimeUnit.MILLISECONDS);
+ } catch (Exception e) {
+ closeClient();
+ setState(State.ERROR, "connect failed: " + e.getMessage());
+ scheduleReconnect();
+ }
+ }
+
+ private void pollOnce() {
+ LuaBridgeClient c = client;
+ if (c == null) {
+ return;
+ }
+ try {
+ StatusInfo st = parseStatus(c.status());
+ setState(State.CONNECTED, st.raw());
+ Consumer pl = pollListener;
+ if (pl != null) {
+ post(() -> pl.accept(st));
+ }
+ } catch (IOException e) {
+ onFailure("lost connection: " + e.getMessage());
+ }
+ }
+
+ private void onFailure(String message) {
+ stopPolling();
+ closeClient();
+ setState(State.ERROR, message);
+ scheduleReconnect();
+ }
+
+ private void scheduleReconnect() {
+ if (!autoReconnect || executor.isShutdown()) {
+ return;
+ }
+ executor.schedule(() -> {
+ if (state == State.ERROR && autoReconnect) {
+ doConnect();
+ }
+ }, RECONNECT_DELAY_MS, TimeUnit.MILLISECONDS);
+ }
+
+ private void stopPolling() {
+ ScheduledFuture> t = pollTask;
+ pollTask = null;
+ if (t != null) {
+ t.cancel(false);
+ }
+ }
+
+ private void closeClient() {
+ LuaBridgeClient c = client;
+ client = null;
+ if (c != null) {
+ try {
+ c.close();
+ } catch (IOException ignored) {
+ }
+ }
+ }
+
+ private void setState(State s, String d) {
+ state = s;
+ detail = d;
+ post(() -> listener.onStateChanged(s, d));
+ }
+
+ private void post(Runnable r) {
+ dispatcher.accept(r);
+ }
+
+ private void submit(Runnable r) {
+ if (executor.isShutdown()) {
+ return;
+ }
+ executor.execute(() -> {
+ try {
+ r.run();
+ } catch (Throwable ignored) {
+ }
+ });
+ }
+
+ @Override
+ public void close() {
+ stopPolling();
+ closeClient();
+ executor.shutdownNow();
+ }
+
+ // ---------- parsing (deterministic, pure) ----------
+
+ static StatusInfo parseStatus(LuaBridgeClient.Reply r) {
+ String raw = r.ok() ? (r.output() != null ? r.output() : "ok")
+ : "error: " + r.error();
+ int scripts = -1, npcs = -1;
+ String world = "";
+ for (String part : raw.split(";")) {
+ String[] kv = part.trim().split("=", 2);
+ if (kv.length == 2) {
+ try {
+ switch (kv[0].trim()) {
+ case "scripts" -> scripts = Integer.parseInt(kv[1].trim());
+ case "npcs" -> npcs = Integer.parseInt(kv[1].trim());
+ case "world" -> world = kv[1].trim();
+ default -> { }
+ }
+ } catch (NumberFormatException ignored) {
+ }
+ }
+ }
+ return new StatusInfo(raw, scripts, npcs, world);
+ }
+
+ static List parseNpcs(LuaBridgeClient.Reply r) {
+ List out = new ArrayList<>();
+ if (!r.ok() || r.result() == null || r.result().isBlank()
+ || "nil".equals(r.result().trim())) {
+ return out;
+ }
+ for (String line : r.result().split("\n")) {
+ String[] f = line.trim().split(",");
+ if (f.length == 4) {
+ try {
+ out.add(new NpcInfo(f[0], Double.parseDouble(f[1]),
+ Double.parseDouble(f[2]), Double.parseDouble(f[3])));
+ } catch (NumberFormatException ignored) {
+ }
+ }
+ }
+ return out;
+ }
+
+ static String replyText(LuaBridgeClient.Reply r) {
+ if (!r.ok()) {
+ return "error: " + r.error();
+ }
+ StringBuilder sb = new StringBuilder();
+ if (r.output() != null && !r.output().isBlank()) {
+ sb.append(r.output());
+ }
+ if (r.result() != null && !r.result().isBlank() && !"nil".equals(r.result().trim())) {
+ if (sb.length() > 0) {
+ sb.append('\n');
+ }
+ sb.append("=> ").append(r.result());
+ }
+ return sb.length() == 0 ? "ok" : sb.toString();
+ }
+
+ /** One bridge request executed on the session thread. */
+ public interface SessionRequest {
+ void run(LuaBridgeClient client) throws IOException;
+
+ void onError(String message);
+ }
+}
diff --git a/ide-plugin/src/main/java/org/communitypoke/luamap/idea/tools/LuaMapToolWindowFactory.java b/ide-plugin/src/main/java/org/communitypoke/luamap/idea/tools/LuaMapToolWindowFactory.java
index 2c338d3..51dc35d 100644
--- a/ide-plugin/src/main/java/org/communitypoke/luamap/idea/tools/LuaMapToolWindowFactory.java
+++ b/ide-plugin/src/main/java/org/communitypoke/luamap/idea/tools/LuaMapToolWindowFactory.java
@@ -1,33 +1,28 @@
package org.communitypoke.luamap.idea.tools;
import com.intellij.openapi.project.Project;
+import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.wm.ToolWindow;
import com.intellij.openapi.wm.ToolWindowFactory;
-import com.intellij.ui.components.JBLabel;
import com.intellij.ui.content.ContentFactory;
-import com.intellij.util.ui.JBUI;
import org.jetbrains.annotations.NotNull;
-import javax.swing.JPanel;
-import java.awt.BorderLayout;
-
/**
- * "LuaMap" tool window — scaffold for a block-preview/inspector panel.
- * Will show region previews fetched via LuaBridge `eval world.getblock(...)`
- * and NPC positions from `npc.list()`.
+ * "LuaMap" tool window — live LuaBridge panel: connection management, NPC
+ * inspector, world/block queries, script runner and eval console. The panel
+ * is {@link com.intellij.openapi.Disposable Disposable}; registering it with
+ * the window closes the session (socket + poll/reconnect threads) when the
+ * tool window content or project is disposed.
*/
public final class LuaMapToolWindowFactory implements ToolWindowFactory {
@Override
public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow window) {
- JPanel panel = new JPanel(new BorderLayout());
- JBLabel label = new JBLabel(
- "LuaMap — block preview / NPC inspector
"
- + "Not connected. Start the game with
"
- + "--bridgePort 25575 and a connection panel will appear here.");
- label.setBorder(JBUI.Borders.empty(12));
- panel.add(label, BorderLayout.NORTH);
- window.getContentManager().addContent(
- ContentFactory.getInstance().createContent(panel, "Preview", false));
+ LuaMapToolWindowPanel panel = new LuaMapToolWindowPanel();
+ var content = ContentFactory.getInstance()
+ .createContent(panel, "LuaBridge", false);
+ // Dispose the session with the tool window content.
+ Disposer.register(content, panel);
+ window.getContentManager().addContent(content);
}
}
diff --git a/ide-plugin/src/main/java/org/communitypoke/luamap/idea/tools/LuaMapToolWindowPanel.java b/ide-plugin/src/main/java/org/communitypoke/luamap/idea/tools/LuaMapToolWindowPanel.java
new file mode 100644
index 0000000..951d48e
--- /dev/null
+++ b/ide-plugin/src/main/java/org/communitypoke/luamap/idea/tools/LuaMapToolWindowPanel.java
@@ -0,0 +1,311 @@
+package org.communitypoke.luamap.idea.tools;
+
+import com.intellij.openapi.Disposable;
+import com.intellij.ui.components.JBLabel;
+import com.intellij.ui.components.JBScrollPane;
+import com.intellij.ui.components.JBTabbedPane;
+import com.intellij.ui.components.JBTextArea;
+import com.intellij.ui.components.JBTextField;
+import com.intellij.ui.table.JBTable;
+import com.intellij.util.ui.JBUI;
+import org.communitypoke.luamap.idea.bridge.LuaBridgeClient;
+import org.communitypoke.luamap.idea.bridge.LuaBridgeSession;
+
+import javax.swing.JButton;
+import javax.swing.JCheckBox;
+import javax.swing.JPanel;
+import javax.swing.JTable;
+import javax.swing.JTextField;
+import javax.swing.SwingUtilities;
+import javax.swing.table.DefaultTableModel;
+import java.awt.BorderLayout;
+import java.awt.FlowLayout;
+import java.util.List;
+
+/**
+ * Live LuaMap panel: connection bar (host/port/connect/state badge),
+ * auto-refreshing NPC inspector, world/block query, and a console for
+ * eval/run results. All socket work happens inside
+ * {@link LuaBridgeSession}'s background thread; this class only ever runs
+ * on the EDT (every callback arrives via {@code invokeLater}).
+ */
+final class LuaMapToolWindowPanel extends JPanel
+ implements Disposable, LuaBridgeSession.Listener {
+
+ private final LuaBridgeSession session;
+
+ private final JTextField hostField = new JBTextField("127.0.0.1", 9);
+ private final JTextField portField =
+ new JBTextField(String.valueOf(LuaBridgeClient.DEFAULT_PORT), 5);
+ private final JButton connectButton = new JButton("Connect");
+ private final JButton disconnectButton = new JButton("Disconnect");
+ private final JButton reconnectButton = new JButton("Reconnect");
+ private final JCheckBox autoReconnectBox = new JCheckBox("Auto-reconnect", true);
+ private final JCheckBox autoRefreshBox = new JCheckBox("Auto-refresh", true);
+ private final JButton refreshButton = new JButton("Refresh");
+ private final JBLabel statusBadge = new JBLabel("Disconnected");
+ private final JBLabel statusDetail = new JBLabel("");
+
+ private final DefaultTableModel npcModel =
+ new DefaultTableModel(new String[]{"Name", "X", "Y", "Z"}, 0) {
+ @Override
+ public boolean isCellEditable(int row, int column) {
+ return false;
+ }
+ };
+ private final JBTable npcTable = new JBTable(npcModel);
+
+ private final JTextField blockX = new JBTextField("0", 4);
+ private final JTextField blockY = new JBTextField("64", 4);
+ private final JTextField blockZ = new JBTextField("0", 4);
+ private final JButton blockQueryButton = new JButton("Query block");
+ private final JBLabel blockResult = new JBLabel("");
+
+ private final DefaultTableModel scriptModel =
+ new DefaultTableModel(new String[]{"Script"}, 0) {
+ @Override
+ public boolean isCellEditable(int row, int column) {
+ return false;
+ }
+ };
+ private final JBTable scriptTable = new JBTable(scriptModel);
+ private final JButton runScriptButton = new JButton("Run");
+ private final JButton reloadScriptsButton = new JButton("Reload list");
+
+ private final JTextField evalField = new JBTextField(20);
+ private final JButton evalButton = new JButton("Eval");
+ private final JBTextArea console = new JBTextArea(8, 40);
+
+ LuaMapToolWindowPanel() {
+ super(new BorderLayout());
+ session = new LuaBridgeSession(this, SwingUtilities::invokeLater);
+ session.setAutoReconnect(true);
+ // Each background poll refreshes the NPC table when auto-refresh is on.
+ session.setPollListener(st -> {
+ if (autoRefreshBox.isSelected() && session.isConnected()) {
+ session.refresh(s -> statusDetail.setText(s.raw()), this::loadNpcs);
+ }
+ });
+
+ statusBadge.setOpaque(true);
+ statusBadge.setBorder(JBUI.Borders.empty(2, 8));
+ console.setEditable(false);
+
+ add(buildTopBar(), BorderLayout.NORTH);
+
+ JBTabbedPane tabs = new JBTabbedPane();
+ tabs.addTab("NPCs", buildNpcTab());
+ tabs.addTab("World", buildWorldTab());
+ tabs.addTab("Console", buildConsoleTab());
+ add(tabs, BorderLayout.CENTER);
+
+ wire();
+ updateForState(LuaBridgeSession.State.DISCONNECTED, "not connected");
+ }
+
+ private JPanel buildTopBar() {
+ JPanel bar = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 4));
+ bar.add(new JBLabel("Host:"));
+ bar.add(hostField);
+ bar.add(new JBLabel("Port:"));
+ bar.add(portField);
+ bar.add(connectButton);
+ bar.add(disconnectButton);
+ bar.add(reconnectButton);
+ bar.add(autoReconnectBox);
+ bar.add(autoRefreshBox);
+ bar.add(refreshButton);
+ bar.add(statusBadge);
+ bar.add(statusDetail);
+ return bar;
+ }
+
+ private JPanel buildNpcTab() {
+ JPanel p = new JPanel(new BorderLayout());
+ npcTable.setAutoCreateRowSorter(true);
+ p.add(new JBScrollPane(npcTable), BorderLayout.CENTER);
+ return p;
+ }
+
+ private JPanel buildWorldTab() {
+ JPanel p = new JPanel(new BorderLayout());
+ JPanel query = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 4));
+ query.add(new JBLabel("getblock("));
+ query.add(blockX);
+ query.add(new JBLabel(","));
+ query.add(blockY);
+ query.add(new JBLabel(","));
+ query.add(blockZ);
+ query.add(new JBLabel(")"));
+ query.add(blockQueryButton);
+ query.add(blockResult);
+ p.add(query, BorderLayout.NORTH);
+
+ JPanel scripts = new JPanel(new BorderLayout());
+ scriptTable.setAutoCreateRowSorter(true);
+ scripts.add(new JBLabel("Scripts (luamaps/)"), BorderLayout.NORTH);
+ scripts.add(new JBScrollPane(scriptTable), BorderLayout.CENTER);
+ JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 2));
+ actions.add(runScriptButton);
+ actions.add(reloadScriptsButton);
+ scripts.add(actions, BorderLayout.SOUTH);
+ p.add(scripts, BorderLayout.CENTER);
+ return p;
+ }
+
+ private JPanel buildConsoleTab() {
+ JPanel p = new JPanel(new BorderLayout());
+ JPanel evalRow = new JPanel(new BorderLayout(4, 0));
+ evalRow.add(new JBLabel("eval:"), BorderLayout.WEST);
+ evalRow.add(evalField, BorderLayout.CENTER);
+ evalRow.add(evalButton, BorderLayout.EAST);
+ p.add(evalRow, BorderLayout.NORTH);
+ p.add(new JBScrollPane(console), BorderLayout.CENTER);
+ return p;
+ }
+
+ private void wire() {
+ connectButton.addActionListener(e -> connectFromFields());
+ reconnectButton.addActionListener(e -> {
+ if (session.state() == LuaBridgeSession.State.DISCONNECTED) {
+ connectFromFields();
+ } else {
+ session.reconnect();
+ }
+ });
+ disconnectButton.addActionListener(e -> session.disconnect());
+ autoReconnectBox.addActionListener(
+ e -> session.setAutoReconnect(autoReconnectBox.isSelected()));
+ refreshButton.addActionListener(e -> refresh());
+ autoRefreshBox.addActionListener(e -> {
+ if (autoRefreshBox.isSelected()) {
+ refresh();
+ }
+ });
+ blockQueryButton.addActionListener(e -> queryBlock());
+ runScriptButton.addActionListener(e -> runSelectedScript());
+ reloadScriptsButton.addActionListener(e -> loadScripts());
+ evalButton.addActionListener(e -> evalField());
+ evalField.addActionListener(e -> evalField());
+ }
+
+ private void connectFromFields() {
+ int port;
+ try {
+ port = Integer.parseInt(portField.getText().trim());
+ if (port < 1 || port > 65535) {
+ throw new NumberFormatException();
+ }
+ } catch (NumberFormatException ex) {
+ updateForState(LuaBridgeSession.State.ERROR,
+ "invalid port '" + portField.getText().trim() + "'");
+ return;
+ }
+ String host = hostField.getText().trim();
+ if (host.isEmpty()) {
+ host = "127.0.0.1";
+ hostField.setText(host);
+ }
+ session.connect(host, port);
+ }
+
+ private void refresh() {
+ if (!session.isConnected()) {
+ return;
+ }
+ session.refresh(
+ st -> statusDetail.setText(st.raw()),
+ this::loadNpcs);
+ loadScripts();
+ }
+
+ private void loadNpcs(List npcs) {
+ npcModel.setRowCount(0);
+ for (LuaBridgeSession.NpcInfo n : npcs) {
+ npcModel.addRow(new Object[]{n.name(), n.x(), n.y(), n.z()});
+ }
+ }
+
+ private void loadScripts() {
+ session.listScripts(names -> {
+ scriptModel.setRowCount(0);
+ for (String n : names) {
+ scriptModel.addRow(new Object[]{n});
+ }
+ });
+ }
+
+ private void queryBlock() {
+ try {
+ int x = Integer.parseInt(blockX.getText().trim());
+ int y = Integer.parseInt(blockY.getText().trim());
+ int z = Integer.parseInt(blockZ.getText().trim());
+ blockResult.setText("…");
+ session.queryBlock(x, y, z, blockResult::setText);
+ } catch (NumberFormatException ex) {
+ blockResult.setText("coordinates must be integers");
+ }
+ }
+
+ private void runSelectedScript() {
+ int row = scriptTable.getSelectedRow();
+ if (row < 0) {
+ log("select a script first");
+ return;
+ }
+ String name = String.valueOf(
+ scriptModel.getValueAt(scriptTable.convertRowIndexToModel(row), 0));
+ log("/luamap run " + name);
+ session.runScript(name, this::log);
+ }
+
+ private void evalField() {
+ String code = evalField.getText().trim();
+ if (code.isEmpty()) {
+ return;
+ }
+ log("> " + code);
+ session.eval(code, this::log);
+ }
+
+ private void log(String line) {
+ console.append(line + "\n");
+ console.setCaretPosition(console.getDocument().getLength());
+ }
+
+ @Override
+ public void onStateChanged(LuaBridgeSession.State state, String detail) {
+ updateForState(state, detail);
+ if (state == LuaBridgeSession.State.CONNECTED) {
+ refresh();
+ }
+ }
+
+ private void updateForState(LuaBridgeSession.State state, String detail) {
+ boolean connected = state == LuaBridgeSession.State.CONNECTED;
+ boolean busy = state == LuaBridgeSession.State.CONNECTING;
+ statusBadge.setText(switch (state) {
+ case CONNECTED -> "Connected";
+ case CONNECTING -> "Connecting…";
+ case ERROR -> "Error";
+ case DISCONNECTED -> "Disconnected";
+ });
+ statusDetail.setText(detail == null ? "" : detail);
+ connectButton.setEnabled(!connected && !busy);
+ disconnectButton.setEnabled(connected || busy);
+ reconnectButton.setEnabled(!busy);
+ refreshButton.setEnabled(connected);
+ blockQueryButton.setEnabled(connected);
+ evalButton.setEnabled(connected);
+ runScriptButton.setEnabled(connected);
+ reloadScriptsButton.setEnabled(connected);
+ if (!connected) {
+ npcModel.setRowCount(0);
+ }
+ }
+
+ @Override
+ public void dispose() {
+ session.close();
+ }
+}
diff --git a/ide-plugin/src/test/java/org/communitypoke/luamap/idea/bridge/LuaBridgeSessionTest.java b/ide-plugin/src/test/java/org/communitypoke/luamap/idea/bridge/LuaBridgeSessionTest.java
new file mode 100644
index 0000000..8a28286
--- /dev/null
+++ b/ide-plugin/src/test/java/org/communitypoke/luamap/idea/bridge/LuaBridgeSessionTest.java
@@ -0,0 +1,222 @@
+package org.communitypoke.luamap.idea.bridge;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Loopback integration tests for {@link LuaBridgeSession} and
+ * {@link LuaBridgeClient} against {@link MockBridgeServer}.
+ */
+class LuaBridgeSessionTest {
+
+ private MockBridgeServer server;
+ private List states;
+ private List details;
+ private LuaBridgeSession session;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ server = new MockBridgeServer();
+ states = new CopyOnWriteArrayList<>();
+ details = new CopyOnWriteArrayList<>();
+ // Synchronous dispatcher — tests assert directly off the session
+ // thread; production passes SwingUtilities::invokeLater.
+ session = new LuaBridgeSession(
+ (s, d) -> {
+ states.add(s);
+ details.add(d);
+ },
+ Runnable::run);
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ session.close();
+ server.close();
+ }
+
+ private void awaitState(LuaBridgeSession.State wanted, long ms)
+ throws InterruptedException {
+ long deadline = System.currentTimeMillis() + ms;
+ while (System.currentTimeMillis() < deadline) {
+ if (session.state() == wanted) {
+ return;
+ }
+ Thread.sleep(10);
+ }
+ // fall through and let the assertion print real state
+ assertEquals(wanted, session.state(),
+ "states seen: " + states + " details: " + details);
+ }
+
+ @Test
+ void connectTransitionsToConnectedAndParsesStatus() throws Exception {
+ session.connect("127.0.0.1", server.port());
+ awaitState(LuaBridgeSession.State.CONNECTED, 5_000);
+ assertTrue(states.contains(LuaBridgeSession.State.CONNECTING));
+ assertTrue(session.isConnected());
+ assertEquals("ok; scripts=3; npcs=2; world=minecraft:overworld",
+ session.detail());
+ assertTrue(server.awaitClient(1_000));
+ }
+
+ @Test
+ void connectToDeadPortEndsInError() throws Exception {
+ // nothing listening on 9 (discard port by convention, unlikely used)
+ session.setAutoReconnect(false);
+ session.connect("127.0.0.1", 9);
+ awaitState(LuaBridgeSession.State.ERROR, 10_000);
+ assertFalse(session.isConnected());
+ assertTrue(session.detail().startsWith("connect failed:"));
+ }
+
+ @Test
+ void disconnectStaysDisconnected() throws Exception {
+ session.connect("127.0.0.1", server.port());
+ awaitState(LuaBridgeSession.State.CONNECTED, 5_000);
+ session.disconnect();
+ awaitState(LuaBridgeSession.State.DISCONNECTED, 5_000);
+ assertEquals("disconnected", session.detail());
+ // Give any stray reconnect a moment — none must fire.
+ Thread.sleep(200);
+ assertEquals(LuaBridgeSession.State.DISCONNECTED, session.state());
+ }
+
+ @Test
+ void droppedConnectionTriggersAutoReconnect() throws Exception {
+ session.connect("127.0.0.1", server.port());
+ awaitState(LuaBridgeSession.State.CONNECTED, 5_000);
+ server.dropConnections();
+ // poll sees the dead socket -> ERROR -> auto reconnect -> CONNECTED
+ awaitState(LuaBridgeSession.State.ERROR, 10_000);
+ awaitState(LuaBridgeSession.State.CONNECTED, 10_000);
+ assertTrue(session.isConnected());
+ }
+
+ @Test
+ void droppedConnectionStaysErrorWhenAutoReconnectOff() throws Exception {
+ session.connect("127.0.0.1", server.port());
+ awaitState(LuaBridgeSession.State.CONNECTED, 5_000);
+ session.setAutoReconnect(false);
+ server.dropConnections();
+ awaitState(LuaBridgeSession.State.ERROR, 10_000);
+ Thread.sleep(LuaBridgeSession.RECONNECT_DELAY_MS + 400);
+ assertEquals(LuaBridgeSession.State.ERROR, session.state());
+ }
+
+ @Test
+ void refreshParsesNpcsAndStatus() throws Exception {
+ session.connect("127.0.0.1", server.port());
+ awaitState(LuaBridgeSession.State.CONNECTED, 5_000);
+
+ CountDownLatch done = new CountDownLatch(1);
+ AtomicReference status = new AtomicReference<>();
+ AtomicReference> npcs = new AtomicReference<>();
+ session.refresh(st -> {
+ status.set(st);
+ done.countDown();
+ }, npcs::set);
+ assertTrue(done.await(5_000, TimeUnit.SECONDS));
+ Thread.sleep(100); // npc callback is a separate post
+ assertEquals(3, status.get().scriptCount());
+ assertEquals(2, status.get().npcCount());
+ assertEquals("minecraft:overworld", status.get().world());
+ assertEquals(List.of(
+ new LuaBridgeSession.NpcInfo("Steve", 10.0, 64.0, -5.0),
+ new LuaBridgeSession.NpcInfo("Alex", -3.5, 70.25, 8.0)),
+ npcs.get());
+ }
+
+ @Test
+ void queryBlockAndEvalRoundTrip() throws Exception {
+ session.connect("127.0.0.1", server.port());
+ awaitState(LuaBridgeSession.State.CONNECTED, 5_000);
+
+ AtomicReference block = new AtomicReference<>();
+ CountDownLatch l1 = new CountDownLatch(1);
+ session.queryBlock(0, 64, 0, s -> {
+ block.set(s);
+ l1.countDown();
+ });
+ assertTrue(l1.await(5_000, TimeUnit.SECONDS));
+ assertEquals("=> minecraft:stone", block.get());
+
+ AtomicReference out = new AtomicReference<>();
+ CountDownLatch l2 = new CountDownLatch(1);
+ session.eval("return 1 + 1", s -> {
+ out.set(s);
+ l2.countDown();
+ });
+ assertTrue(l2.await(5_000, TimeUnit.SECONDS));
+ assertTrue(out.get().contains("chat line one"));
+ assertTrue(out.get().contains("=> eval-result"));
+ }
+
+ @Test
+ void listScriptsParsesResult() throws Exception {
+ session.connect("127.0.0.1", server.port());
+ awaitState(LuaBridgeSession.State.CONNECTED, 5_000);
+ AtomicReference> scripts = new AtomicReference<>();
+ CountDownLatch l = new CountDownLatch(1);
+ session.listScripts(s -> {
+ scripts.set(s);
+ l.countDown();
+ });
+ assertTrue(l.await(5_000, TimeUnit.SECONDS));
+ assertEquals(List.of("hello", "arena", "parkour"), scripts.get());
+ }
+
+ @Test
+ void errorReplySurfacesMessage() throws Exception {
+ server.setResponder(req -> MockBridgeServer.reply(
+ req.get("id").getAsLong(), false, null, "boom"));
+ session.connect("127.0.0.1", server.port());
+ // connect calls status(); an error reply still leaves the socket open
+ awaitState(LuaBridgeSession.State.CONNECTED, 5_000);
+ AtomicReference out = new AtomicReference<>();
+ CountDownLatch l = new CountDownLatch(1);
+ session.eval("broken()", s -> {
+ out.set(s);
+ l.countDown();
+ });
+ assertTrue(l.await(5_000, TimeUnit.SECONDS));
+ assertEquals("error: boom", out.get());
+ }
+
+ @Test
+ void requestsAreSentAsNdjsonProtocolV1() throws Exception {
+ try (LuaBridgeClient c = new LuaBridgeClient("127.0.0.1", server.port())) {
+ LuaBridgeClient.Reply r = c.eval("return 1");
+ assertTrue(r.ok());
+ }
+ List reqs = server.requests();
+ assertEquals(1, reqs.size());
+ var req = reqs.get(0);
+ assertEquals(1, req.get("v").getAsInt());
+ assertEquals("eval", req.get("op").getAsString());
+ assertEquals("return 1", req.get("code").getAsString());
+ assertTrue(req.get("id").getAsLong() > 0);
+ }
+
+ @Test
+ void sessionNeverBlocksCallerThread() throws Exception {
+ // The caller thread (EDT in production) must return immediately —
+ // measure that connect() itself does no socket I/O by pointing at a
+ // slow-to-respond endpoint and asserting prompt return.
+ long start = System.currentTimeMillis();
+ session.connect("10.255.255.1", 65000); // unroutable; connect() must not block
+ assertTrue(System.currentTimeMillis() - start < 1_000,
+ "connect() blocked the caller thread");
+ }
+}
diff --git a/ide-plugin/src/test/java/org/communitypoke/luamap/idea/bridge/MockBridgeServer.java b/ide-plugin/src/test/java/org/communitypoke/luamap/idea/bridge/MockBridgeServer.java
new file mode 100644
index 0000000..23208da
--- /dev/null
+++ b/ide-plugin/src/test/java/org/communitypoke/luamap/idea/bridge/MockBridgeServer.java
@@ -0,0 +1,160 @@
+package org.communitypoke.luamap.idea.bridge;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+
+/**
+ * Loopback NDJSON server impersonating LuaBridge for tests. Answers each
+ * request line through a pluggable responder; {@link #dropConnections}
+ * simulates a server going away; {@link #requests} records every request.
+ */
+final class MockBridgeServer implements AutoCloseable {
+
+ private final ServerSocket server;
+ private final Thread acceptor;
+ private final List clients = new CopyOnWriteArrayList<>();
+ private final ConcurrentLinkedQueue requests =
+ new ConcurrentLinkedQueue<>();
+ private final CountDownLatch accepted = new CountDownLatch(1);
+ private volatile Function responder;
+ private volatile boolean running = true;
+
+ MockBridgeServer() throws IOException {
+ server = new ServerSocket(0);
+ responder = MockBridgeServer::defaultReply;
+ acceptor = new Thread(this::acceptLoop, "mock-bridge-accept");
+ acceptor.setDaemon(true);
+ acceptor.start();
+ }
+
+ int port() {
+ return server.getLocalPort();
+ }
+
+ void setResponder(Function r) {
+ responder = r;
+ }
+
+ /** All requests received so far. */
+ List requests() {
+ return List.copyOf(requests);
+ }
+
+ /** Wait for at least one client connection. */
+ boolean awaitClient(long ms) throws InterruptedException {
+ return accepted.await(ms, TimeUnit.MILLISECONDS);
+ }
+
+ /** Forcibly close every accepted client socket (simulates a crash). */
+ void dropConnections() throws IOException {
+ for (Socket s : clients) {
+ s.close();
+ }
+ clients.clear();
+ }
+
+ private void acceptLoop() {
+ while (running) {
+ try {
+ Socket s = server.accept();
+ clients.add(s);
+ accepted.countDown();
+ Thread reader = new Thread(() -> serve(s), "mock-bridge-reader");
+ reader.setDaemon(true);
+ reader.start();
+ } catch (IOException e) {
+ if (running) {
+ throw new RuntimeException(e);
+ }
+ return;
+ }
+ }
+ }
+
+ private void serve(Socket s) {
+ try (s;
+ BufferedReader in = new BufferedReader(
+ new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));
+ PrintWriter out = new PrintWriter(new OutputStreamWriter(
+ s.getOutputStream(), StandardCharsets.UTF_8), true)) {
+ String line;
+ while ((line = in.readLine()) != null) {
+ JsonObject req = JsonParser.parseString(line).getAsJsonObject();
+ requests.add(req);
+ JsonObject resp = responder.apply(req);
+ if (resp != null) {
+ out.println(resp);
+ }
+ }
+ } catch (IOException ignored) {
+ // client hung up or we dropped it — normal in these tests
+ } finally {
+ clients.remove(s);
+ }
+ }
+
+ private static JsonObject defaultReply(JsonObject req) {
+ long id = req.get("id").getAsLong();
+ return switch (req.get("op").getAsString()) {
+ case "status" -> reply(id, true, null,
+ "ok; scripts=3; npcs=2; world=minecraft:overworld");
+ case "list" -> reply(id, true, "hello\narena\nparkour", null);
+ case "eval" -> {
+ String code = req.has("code") ? req.get("code").getAsString() : "";
+ if (code.contains("npc.list")) {
+ yield reply(id, true,
+ "Steve,10.00,64.00,-5.00\nAlex,-3.50,70.25,8.00", null);
+ }
+ if (code.contains("world.getblock")) {
+ yield reply(id, true, "minecraft:stone", null);
+ }
+ yield reply(id, true, "eval-result", "chat line one");
+ }
+ case "run" -> reply(id, true, null, "ran " +
+ (req.has("name") ? req.get("name").getAsString() : "?"));
+ case "reload" -> reply(id, true, "ok; 120 bytes", null);
+ default -> reply(id, false, null, "unknown op '" +
+ req.get("op").getAsString() + "'");
+ };
+ }
+
+ static JsonObject reply(long id, boolean ok, String result, String output) {
+ JsonObject r = new JsonObject();
+ r.addProperty("v", 1);
+ r.addProperty("id", id);
+ r.addProperty("ok", ok);
+ if (result != null) {
+ r.addProperty("result", result);
+ }
+ if (output != null) {
+ r.addProperty("output", output);
+ }
+ if (!ok) {
+ r.addProperty("error", output == null ? "error" : output);
+ }
+ return r;
+ }
+
+ @Override
+ public void close() throws IOException {
+ running = false;
+ dropConnections();
+ server.close();
+ }
+}
diff --git a/launcher/build.gradle b/launcher/build.gradle
index f9f9f46..b11a167 100644
--- a/launcher/build.gradle
+++ b/launcher/build.gradle
@@ -115,8 +115,10 @@ tasks.register('verifyLauncherJar') {
check {
dependsOn tasks.named('verifyLauncherJar')
- // Also verify the plugin zip's packaged compatibility range.
+ // Also verify the plugin zip's packaged compatibility range, and run the
+ // plugin's plain-JVM unit tests (its IDE-harness `test` task is disabled).
dependsOn gradle.includedBuild('ide-plugin').task(':verifyPluginXml')
+ dependsOn gradle.includedBuild('ide-plugin').task(':unitTest')
}
build {