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 {