From b4047c83cffa256aa9e0609155ebc9a6e100e6ea Mon Sep 17 00:00:00 2001 From: Cervator Date: Sat, 28 Mar 2026 10:21:32 -0400 Subject: [PATCH 1/5] feat: add Nakama subsystem for Bifrost cross-game chat Chunk 2 of the Bifrost First Contact plan. Adds a Nakama engine subsystem that bridges Gestalt chat events to a shared Nakama chat channel, enabling cross-game messaging. Includes NakamaConfig (system properties), NakamaSubSystem (connection lifecycle, send/receive), NakamaSystem (entity system for chat events), and engine registration. Uses Nakama Java SDK 2.5.3 via JitPack. Co-Authored-By: Claude Opus 4.6 --- .../engine/core/TerasologyEngine.java | 3 +- facades/PC/build.gradle.kts | 8 + .../org/terasology/engine/Terasology.java | 1 + subsystems/Nakama/build.gradle.kts | 30 +++ .../subsystem/nakama/NakamaConfig.java | 45 ++++ .../subsystem/nakama/NakamaSubSystem.java | 219 ++++++++++++++++++ .../subsystem/nakama/NakamaSystem.java | 32 +++ .../subsystem/nakama/NakamaConfigTest.java | 17 ++ 8 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 subsystems/Nakama/build.gradle.kts create mode 100644 subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaConfig.java create mode 100644 subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java create mode 100644 subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSystem.java create mode 100644 subsystems/Nakama/src/test/java/org/terasology/subsystem/nakama/NakamaConfigTest.java diff --git a/engine/src/main/java/org/terasology/engine/core/TerasologyEngine.java b/engine/src/main/java/org/terasology/engine/core/TerasologyEngine.java index 9b704e6ffb7..263db499121 100644 --- a/engine/src/main/java/org/terasology/engine/core/TerasologyEngine.java +++ b/engine/src/main/java/org/terasology/engine/core/TerasologyEngine.java @@ -111,7 +111,8 @@ public class TerasologyEngine implements GameEngine { * You don't want to add to this! If you need a module, make a module! */ private static final Set LEGACY_ENGINE_MODULE_POLLUTERS = Set.of( - "org.terasology.subsystem.discordrpc.DiscordRPCSubSystem" + "org.terasology.subsystem.discordrpc.DiscordRPCSubSystem", + "org.terasology.subsystem.nakama.NakamaSubSystem" ); private final List> classesOnClasspathsToAddToEngine = new ArrayList<>(); diff --git a/facades/PC/build.gradle.kts b/facades/PC/build.gradle.kts index d5753c734e4..d2fa17358a5 100644 --- a/facades/PC/build.gradle.kts +++ b/facades/PC/build.gradle.kts @@ -19,6 +19,13 @@ plugins { id("facade") } +repositories { + maven { + name = "JitPack" + url = uri("https://jitpack.io") + } +} + // Grab all the common stuff like plugins to use, artifact repositories, code analysis config apply(from = "$rootDir/config/gradle/publish.gradle") @@ -64,6 +71,7 @@ dependencies { implementation(project(":engine")) implementation(project(":subsystems:DiscordRPC")) + implementation(project(":subsystems:Nakama")) implementation("io.projectreactor:reactor-core:3.4.7") // TODO: Consider whether we can move the CR dependency back here from the engine, where it is referenced from the main menu diff --git a/facades/PC/src/main/java/org/terasology/engine/Terasology.java b/facades/PC/src/main/java/org/terasology/engine/Terasology.java index 215862b7290..f6e0c901816 100644 --- a/facades/PC/src/main/java/org/terasology/engine/Terasology.java +++ b/facades/PC/src/main/java/org/terasology/engine/Terasology.java @@ -314,6 +314,7 @@ private void populateSubsystems(TerasologyEngineBuilder builder) { .add(new LwjglInput()) .add(new BindsSubsystem()); builder.add(new DiscordRPCSubSystem()); + builder.add(new org.terasology.subsystem.nakama.NakamaSubSystem()); } builder.add(new HibernationSubsystem()); } diff --git a/subsystems/Nakama/build.gradle.kts b/subsystems/Nakama/build.gradle.kts new file mode 100644 index 00000000000..6f981050b99 --- /dev/null +++ b/subsystems/Nakama/build.gradle.kts @@ -0,0 +1,30 @@ +// Nakama subsystem - optional Bifrost integration +// Bridges Gestalt chat events to/from a Nakama chat channel + +plugins { + java + `java-library` + id("terasology-common") +} + +apply(from = "$rootDir/config/gradle/common.gradle") + +configure { + main { java.destinationDirectory.set(layout.buildDirectory.dir("classes")) } + test { java.destinationDirectory.set(layout.buildDirectory.dir("testClasses")) } +} + +repositories { + maven { + name = "JitPack" + url = uri("https://jitpack.io") + } +} + +dependencies { + implementation(project(":engine")) + + annotationProcessor(libs.gestalt.injectjava) + + api("com.github.heroiclabs.nakama-java:nakama-java:2.5.3") +} diff --git a/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaConfig.java b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaConfig.java new file mode 100644 index 00000000000..9ad420eda56 --- /dev/null +++ b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaConfig.java @@ -0,0 +1,45 @@ +// Copyright 2026 The Terasology Foundation +// SPDX-License-Identifier: Apache-2.0 + +package org.terasology.subsystem.nakama; + +/** + * Configuration for the Nakama subsystem. + * Read from system properties for the POC. + */ +public class NakamaConfig { + private boolean enabled = false; + private String host = "localhost"; + private int port = 7349; + private String channel = "bifrost.lobby"; + private String playerName = ""; + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + + public String getHost() { return host; } + public void setHost(String host) { this.host = host; } + + public int getPort() { return port; } + public void setPort(int port) { this.port = port; } + + public String getChannel() { return channel; } + public void setChannel(String channel) { this.channel = channel; } + + public String getPlayerName() { return playerName; } + public void setPlayerName(String playerName) { this.playerName = playerName; } + + /** + * Load config from system properties (nakama.enabled, nakama.host, etc.) + * Falls back to defaults if not set. + */ + public static NakamaConfig fromSystemProperties() { + NakamaConfig config = new NakamaConfig(); + config.setEnabled(Boolean.parseBoolean(System.getProperty("nakama.enabled", "false"))); + config.setHost(System.getProperty("nakama.host", "localhost")); + config.setPort(Integer.parseInt(System.getProperty("nakama.port", "7349"))); + config.setChannel(System.getProperty("nakama.channel", "bifrost.lobby")); + config.setPlayerName(System.getProperty("nakama.playerName", "")); + return config; + } +} diff --git a/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java new file mode 100644 index 00000000000..01a0306a120 --- /dev/null +++ b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java @@ -0,0 +1,219 @@ +// Copyright 2026 The Terasology Foundation +// SPDX-License-Identifier: Apache-2.0 + +package org.terasology.subsystem.nakama; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.heroiclabs.nakama.AbstractSocketListener; +import com.heroiclabs.nakama.Channel; +import com.heroiclabs.nakama.api.ChannelMessage; +import com.heroiclabs.nakama.ChannelType; +import com.heroiclabs.nakama.Client; +import com.heroiclabs.nakama.DefaultClient; +import com.heroiclabs.nakama.Session; +import com.heroiclabs.nakama.SocketClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.terasology.engine.context.Context; +import org.terasology.engine.core.ComponentSystemManager; +import org.terasology.engine.core.GameEngine; +import org.terasology.engine.core.subsystem.EngineSubsystem; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; + +/** + * Optional engine subsystem that bridges Terasology chat to a Nakama + * chat channel, enabling cross-game messaging for the Bifrost protocol. + * + * Enable via system property: -Dnakama.enabled=true -Dnakama.host=192.168.x.x + */ +public class NakamaSubSystem implements EngineSubsystem { + private static final Logger logger = LoggerFactory.getLogger(NakamaSubSystem.class); + private static final String GAME_ID = "terasology"; + private static final Map GAME_PREFIXES = Map.of( + "terasology", "TS", "destinationsol", "DS", "minecraft", "MC" + ); + + private NakamaConfig config; + private Client client; + private Session session; + private SocketClient socket; + private Channel channel; + + // Callback for incoming messages — set by the engine/module that handles chat display + private Consumer incomingMessageHandler; + + // Flag to prevent re-forwarding messages we injected + private volatile boolean suppressOutbound = false; + + @Override + public String getName() { + return "Nakama"; + } + + @Override + public void initialise(GameEngine engine, Context rootContext) { + config = NakamaConfig.fromSystemProperties(); + if (!config.isEnabled()) { + logger.info("Nakama subsystem disabled (set -Dnakama.enabled=true to enable)"); + return; + } + connect(); + } + + private void connect() { + try { + String deviceId = getOrCreateDeviceId(); + // DefaultClient(serverKey, host, port, ssl) — port is HTTP/WebSocket port + client = new DefaultClient("defaultkey", config.getHost(), config.getPort(), false); + session = client.authenticateDevice(deviceId).get(); + logger.info("Nakama: authenticated as {}", session.getUserId()); + + socket = client.createSocket(); + socket.connect(session, new AbstractSocketListener() { + @Override + public void onChannelMessage(ChannelMessage message) { + handleIncomingMessage(message); + } + }).get(); + + // Join the shared chat channel + channel = socket.joinChat(config.getChannel(), ChannelType.ROOM).get(); + logger.info("Nakama: joined channel '{}'", config.getChannel()); + + } catch (Exception e) { + logger.warn("Nakama: connection failed, continuing without cross-game chat", e); + cleanup(); + } + } + + private void handleIncomingMessage(ChannelMessage message) { + try { + JsonObject content = JsonParser.parseString(message.getContent()).getAsJsonObject(); + String game = content.has("game") ? content.get("game").getAsString() : ""; + // Echo filter: ignore our own game's messages + if (GAME_ID.equals(game)) { + return; + } + String player = content.has("player") ? content.get("player").getAsString() : "???"; + String text = content.has("text") ? content.get("text").getAsString() : ""; + String prefix = "[" + GAME_PREFIXES.getOrDefault(game, + game.toUpperCase().substring(0, Math.min(game.length(), 2))) + "]"; + String formatted = prefix + " " + player + ": " + text; + + if (incomingMessageHandler != null) { + suppressOutbound = true; + try { + incomingMessageHandler.accept(formatted); + } finally { + suppressOutbound = false; + } + } + } catch (Exception e) { + logger.warn("Nakama: failed to parse incoming message", e); + } + } + + /** + * Send a chat message to the Nakama channel. + * Called by the chat system when a local player sends a message. + * Returns true if the message was sent, false if suppressed or not connected. + */ + public boolean sendChatMessage(String playerName, String text) { + if (suppressOutbound || socket == null || channel == null) { + return false; + } + try { + JsonObject content = new JsonObject(); + content.addProperty("game", GAME_ID); + content.addProperty("player", playerName); + content.addProperty("text", text); + socket.writeChatMessage(channel.getId(), content.toString()).get(); + return true; + } catch (Exception e) { + logger.warn("Nakama: failed to send message", e); + return false; + } + } + + /** + * Register a handler for incoming cross-game messages. + * The handler receives a pre-formatted string like "[DS] Bob: Hello" + */ + public void setIncomingMessageHandler(Consumer handler) { + this.incomingMessageHandler = handler; + } + + @Override + public void registerSystems(ComponentSystemManager componentSystemManager) { + if (config != null && config.isEnabled()) { + NakamaSystem nakamaSystem = new NakamaSystem(); + nakamaSystem.setNakamaSubSystem(this); + componentSystemManager.register(nakamaSystem); + } + } + + @Override + public void postInitialise(Context context) { + if (config == null || !config.isEnabled() || !isConnected()) { + return; + } + // Inbound: inject Nakama messages into the local chat system + setIncomingMessageHandler(formatted -> { + // For POC, log to the game log. Full chat injection requires + // accessing the NUI ChatBox or sending a synthetic ChatMessageEvent. + logger.info("Nakama chat: {}", formatted); + }); + } + + public boolean isConnected() { + return socket != null && channel != null; + } + + @Override + public void preShutdown() { + cleanup(); + } + + private void cleanup() { + if (socket != null) { + try { socket.disconnect(); } catch (Exception ignored) { } + socket = null; + } + channel = null; + session = null; + client = null; + } + + private String getOrCreateDeviceId() { + String id = System.getProperty("nakama.deviceId", ""); + if (!id.isEmpty()) { + return id; + } + // Persist device ID to a file so we get the same Nakama user across restarts + Path idFile = Paths.get(System.getProperty("user.home"), ".bifrost", "device-id"); + try { + if (Files.exists(idFile)) { + id = Files.readString(idFile).trim(); + if (!id.isEmpty()) { + return id; + } + } + id = UUID.randomUUID().toString(); + Files.createDirectories(idFile.getParent()); + Files.writeString(idFile, id); + logger.info("Nakama: created device ID {}", id); + } catch (IOException e) { + id = UUID.randomUUID().toString(); + logger.warn("Nakama: could not persist device ID, using ephemeral {}", id); + } + return id; + } +} diff --git a/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSystem.java b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSystem.java new file mode 100644 index 00000000000..e9020b2625d --- /dev/null +++ b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSystem.java @@ -0,0 +1,32 @@ +// Copyright 2026 The Terasology Foundation +// SPDX-License-Identifier: Apache-2.0 + +package org.terasology.subsystem.nakama; + +import org.terasology.engine.entitySystem.entity.EntityRef; +import org.terasology.engine.entitySystem.systems.BaseComponentSystem; +import org.terasology.engine.entitySystem.systems.RegisterSystem; +import org.terasology.engine.logic.chat.ChatMessageEvent; +import org.terasology.engine.network.ClientComponent; +import org.terasology.gestalt.entitysystem.event.ReceiveEvent; + +/** + * Entity system that bridges Gestalt chat events to the NakamaSubSystem. + * Registered via NakamaSubSystem.registerSystems(). + */ +@RegisterSystem +public class NakamaSystem extends BaseComponentSystem { + private NakamaSubSystem nakamaSubSystem; + + public void setNakamaSubSystem(NakamaSubSystem subsystem) { + this.nakamaSubSystem = subsystem; + } + + @ReceiveEvent(components = ClientComponent.class) + public void onChatMessage(ChatMessageEvent event, EntityRef entity) { + if (nakamaSubSystem != null && nakamaSubSystem.isConnected()) { + String playerName = event.getFrom().toString(); + nakamaSubSystem.sendChatMessage(playerName, event.getMessage()); + } + } +} diff --git a/subsystems/Nakama/src/test/java/org/terasology/subsystem/nakama/NakamaConfigTest.java b/subsystems/Nakama/src/test/java/org/terasology/subsystem/nakama/NakamaConfigTest.java new file mode 100644 index 00000000000..c542314cc3a --- /dev/null +++ b/subsystems/Nakama/src/test/java/org/terasology/subsystem/nakama/NakamaConfigTest.java @@ -0,0 +1,17 @@ +// Copyright 2026 The Terasology Foundation +// SPDX-License-Identifier: Apache-2.0 + +package org.terasology.subsystem.nakama; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class NakamaConfigTest { + @Test + void defaultsAreDisabled() { + NakamaConfig config = new NakamaConfig(); + assertFalse(config.isEnabled()); + assertEquals("bifrost.lobby", config.getChannel()); + assertEquals(7349, config.getPort()); + } +} From e7b2d457e6e40b0840aa17b50b2a1e6d101d2d81 Mon Sep 17 00:00:00 2001 From: Cervator Date: Sat, 28 Mar 2026 11:15:27 -0400 Subject: [PATCH 2/5] test: add Nakama integration tests and dual-port config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add integration tests that verify auth, channel join, and message send/receive against a live Nakama server. Tests are tagged @integration and excluded from normal builds. Also split NakamaConfig into separate grpcPort (7349) and wsPort (7350) since the SDK uses gRPC for API and WebSocket for realtime — these map to different NodePorts in k8s. Co-Authored-By: Claude Opus 4.6 --- subsystems/Nakama/build.gradle.kts | 15 +++ .../subsystem/nakama/NakamaConfig.java | 22 +++- .../subsystem/nakama/NakamaSubSystem.java | 7 +- .../subsystem/nakama/NakamaConfigTest.java | 3 +- .../nakama/NakamaIntegrationTest.java | 119 ++++++++++++++++++ 5 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 subsystems/Nakama/src/test/java/org/terasology/subsystem/nakama/NakamaIntegrationTest.java diff --git a/subsystems/Nakama/build.gradle.kts b/subsystems/Nakama/build.gradle.kts index 6f981050b99..36df260deff 100644 --- a/subsystems/Nakama/build.gradle.kts +++ b/subsystems/Nakama/build.gradle.kts @@ -28,3 +28,18 @@ dependencies { api("com.github.heroiclabs.nakama-java:nakama-java:2.5.3") } + +tasks.named("test") { + // Exclude integration tests by default — they need a running Nakama server + useJUnitPlatform { + if (!project.hasProperty("includeTags")) { + excludeTags("integration") + } else { + includeTags(project.property("includeTags") as String) + } + } + // Pass nakama.test.* system properties through to the test JVM + System.getProperties().entries + .filter { (it.key as String).startsWith("nakama.test.") } + .forEach { systemProperty(it.key as String, it.value) } +} diff --git a/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaConfig.java b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaConfig.java index 9ad420eda56..71ec67bf095 100644 --- a/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaConfig.java +++ b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaConfig.java @@ -10,7 +10,8 @@ public class NakamaConfig { private boolean enabled = false; private String host = "localhost"; - private int port = 7349; + private int grpcPort = 7349; + private int wsPort = 7350; private String channel = "bifrost.lobby"; private String playerName = ""; @@ -20,8 +21,20 @@ public class NakamaConfig { public String getHost() { return host; } public void setHost(String host) { this.host = host; } - public int getPort() { return port; } - public void setPort(int port) { this.port = port; } + /** gRPC port for API calls (auth, account). Default: 7349. */ + public int getGrpcPort() { return grpcPort; } + public void setGrpcPort(int grpcPort) { this.grpcPort = grpcPort; } + + /** @deprecated Use {@link #getGrpcPort()}. Kept for backwards compatibility. */ + @Deprecated + public int getPort() { return grpcPort; } + /** @deprecated Use {@link #setGrpcPort(int)}. */ + @Deprecated + public void setPort(int port) { this.grpcPort = port; } + + /** WebSocket port for realtime (chat, presence). Default: 7350. */ + public int getWsPort() { return wsPort; } + public void setWsPort(int wsPort) { this.wsPort = wsPort; } public String getChannel() { return channel; } public void setChannel(String channel) { this.channel = channel; } @@ -37,7 +50,8 @@ public static NakamaConfig fromSystemProperties() { NakamaConfig config = new NakamaConfig(); config.setEnabled(Boolean.parseBoolean(System.getProperty("nakama.enabled", "false"))); config.setHost(System.getProperty("nakama.host", "localhost")); - config.setPort(Integer.parseInt(System.getProperty("nakama.port", "7349"))); + config.setGrpcPort(Integer.parseInt(System.getProperty("nakama.grpcPort", "7349"))); + config.setWsPort(Integer.parseInt(System.getProperty("nakama.wsPort", "7350"))); config.setChannel(System.getProperty("nakama.channel", "bifrost.lobby")); config.setPlayerName(System.getProperty("nakama.playerName", "")); return config; diff --git a/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java index 01a0306a120..f47dd08f28a 100644 --- a/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java +++ b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java @@ -71,12 +71,13 @@ public void initialise(GameEngine engine, Context rootContext) { private void connect() { try { String deviceId = getOrCreateDeviceId(); - // DefaultClient(serverKey, host, port, ssl) — port is HTTP/WebSocket port - client = new DefaultClient("defaultkey", config.getHost(), config.getPort(), false); + // DefaultClient uses gRPC for API calls (auth, etc.) + client = new DefaultClient("defaultkey", config.getHost(), config.getGrpcPort(), false); session = client.authenticateDevice(deviceId).get(); logger.info("Nakama: authenticated as {}", session.getUserId()); - socket = client.createSocket(); + // createSocket uses WebSocket for realtime — may be a different port via NodePort + socket = client.createSocket(config.getHost(), config.getWsPort(), false); socket.connect(session, new AbstractSocketListener() { @Override public void onChannelMessage(ChannelMessage message) { diff --git a/subsystems/Nakama/src/test/java/org/terasology/subsystem/nakama/NakamaConfigTest.java b/subsystems/Nakama/src/test/java/org/terasology/subsystem/nakama/NakamaConfigTest.java index c542314cc3a..bd3c64b0404 100644 --- a/subsystems/Nakama/src/test/java/org/terasology/subsystem/nakama/NakamaConfigTest.java +++ b/subsystems/Nakama/src/test/java/org/terasology/subsystem/nakama/NakamaConfigTest.java @@ -12,6 +12,7 @@ void defaultsAreDisabled() { NakamaConfig config = new NakamaConfig(); assertFalse(config.isEnabled()); assertEquals("bifrost.lobby", config.getChannel()); - assertEquals(7349, config.getPort()); + assertEquals(7349, config.getGrpcPort()); + assertEquals(7350, config.getWsPort()); } } diff --git a/subsystems/Nakama/src/test/java/org/terasology/subsystem/nakama/NakamaIntegrationTest.java b/subsystems/Nakama/src/test/java/org/terasology/subsystem/nakama/NakamaIntegrationTest.java new file mode 100644 index 00000000000..1f4174c4e93 --- /dev/null +++ b/subsystems/Nakama/src/test/java/org/terasology/subsystem/nakama/NakamaIntegrationTest.java @@ -0,0 +1,119 @@ +// Copyright 2026 The Terasology Foundation +// SPDX-License-Identifier: Apache-2.0 + +package org.terasology.subsystem.nakama; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.heroiclabs.nakama.AbstractSocketListener; +import com.heroiclabs.nakama.Channel; +import com.heroiclabs.nakama.ChannelType; +import com.heroiclabs.nakama.Client; +import com.heroiclabs.nakama.DefaultClient; +import com.heroiclabs.nakama.Session; +import com.heroiclabs.nakama.SocketClient; +import com.heroiclabs.nakama.api.ChannelMessage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration test for the Nakama connection. + * Requires a running Nakama server — skipped in normal builds. + * + * The SDK uses two ports: gRPC for API calls (auth, etc.) and WebSocket for + * realtime (chat, presence). When using NodePort, these are different ports. + * + * Run with: ./gradlew :subsystems:Nakama:test -PincludeTags=integration + * -Dnakama.test.host=localhost + * -Dnakama.test.grpcPort=7349 + * -Dnakama.test.wsPort=7350 + */ +@Tag("integration") +class NakamaIntegrationTest { + + private static final String TEST_CHANNEL = "bifrost.test." + UUID.randomUUID().toString().substring(0, 8); + + private final String host = System.getProperty("nakama.test.host", "localhost"); + private final int grpcPort = Integer.parseInt(System.getProperty("nakama.test.grpcPort", "7349")); + private final int wsPort = Integer.parseInt(System.getProperty("nakama.test.wsPort", "7350")); + + private Client senderClient; + private Client receiverClient; + private SocketClient senderSocket; + private SocketClient receiverSocket; + + @AfterEach + void cleanup() { + if (senderSocket != null) try { senderSocket.disconnect(); } catch (Exception ignored) { } + if (receiverSocket != null) try { receiverSocket.disconnect(); } catch (Exception ignored) { } + } + + @Test + void canAuthenticateAndJoinChannel() throws Exception { + senderClient = new DefaultClient("defaultkey", host, grpcPort, false); + Session session = senderClient.authenticateDevice(UUID.randomUUID().toString()).get(); + + assertNotNull(session.getUserId(), "Should receive a user ID after auth"); + assertFalse(session.IsExpired(), "Session should not be expired"); + + senderSocket = senderClient.createSocket(host, wsPort, false); + senderSocket.connect(session, new AbstractSocketListener() {}).get(); + + Channel channel = senderSocket.joinChat(TEST_CHANNEL, ChannelType.ROOM).get(); + assertNotNull(channel.getId(), "Should receive a channel ID after joining"); + } + + @Test + void canSendAndReceiveMessage() throws Exception { + // Set up receiver first + receiverClient = new DefaultClient("defaultkey", host, grpcPort, false); + Session receiverSession = receiverClient.authenticateDevice(UUID.randomUUID().toString()).get(); + + CountDownLatch messageLatch = new CountDownLatch(1); + AtomicReference receivedContent = new AtomicReference<>(); + + receiverSocket = receiverClient.createSocket(host, wsPort, false); + receiverSocket.connect(receiverSession, new AbstractSocketListener() { + @Override + public void onChannelMessage(ChannelMessage message) { + receivedContent.set(message.getContent()); + messageLatch.countDown(); + } + }).get(); + + Channel receiverChannel = receiverSocket.joinChat(TEST_CHANNEL, ChannelType.ROOM).get(); + + // Set up sender + senderClient = new DefaultClient("defaultkey", host, grpcPort, false); + Session senderSession = senderClient.authenticateDevice(UUID.randomUUID().toString()).get(); + + senderSocket = senderClient.createSocket(host, wsPort, false); + senderSocket.connect(senderSession, new AbstractSocketListener() {}).get(); + senderSocket.joinChat(TEST_CHANNEL, ChannelType.ROOM).get(); + + // Send a message + JsonObject payload = new JsonObject(); + payload.addProperty("game", "terasology"); + payload.addProperty("player", "TestAlice"); + payload.addProperty("text", "Hello from integration test!"); + senderSocket.writeChatMessage(receiverChannel.getId(), payload.toString()).get(); + + // Wait for receiver to get the message + boolean received = messageLatch.await(10, TimeUnit.SECONDS); + assertTrue(received, "Should receive message within 10 seconds"); + + // Verify message content + JsonObject parsed = JsonParser.parseString(receivedContent.get()).getAsJsonObject(); + assertEquals("terasology", parsed.get("game").getAsString()); + assertEquals("TestAlice", parsed.get("player").getAsString()); + assertEquals("Hello from integration test!", parsed.get("text").getAsString()); + } +} From ece87bf5f4b9e7449c719e41ee33a36f41f70c90 Mon Sep 17 00:00:00 2001 From: Cervator Date: Sat, 28 Mar 2026 23:48:28 -0400 Subject: [PATCH 3/5] feat: add NakamaAutoConfig and upgrade protobuf to 4.28.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded Nakama config with AutoConfig integration following the DiscordRPC pattern. Config auto-saves to ~/.terasology/configs/nakama/. Upgrade protobuf from 3.22.5 to 4.28.2 to match Nakama SDK requirements — EntityData.java is auto-generated by the protobuf Gradle plugin so no manual regeneration needed. Co-Authored-By: Claude Opus 4.6 --- settings.gradle.kts | 2 +- .../subsystem/nakama/NakamaAutoConfig.java | 59 +++++++++++++++++++ .../subsystem/nakama/NakamaSubSystem.java | 24 ++++---- .../subsystem/nakama/NakamaConfigTest.java | 14 +++-- 4 files changed, 79 insertions(+), 20 deletions(-) create mode 100644 subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaAutoConfig.java diff --git a/settings.gradle.kts b/settings.gradle.kts index cbda39a3bc2..470b6799464 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,7 +26,7 @@ dependencyResolutionManagement { library("mockito-inline", "org.mockito:mockito-inline:3.12.4") library("mockito-junit", "org.mockito", "mockito-junit-jupiter").versionRef(mockito) // protobuf does not work as the others, see https://github.com/google/protobuf-gradle-plugin/issues/563 - val protobuf = version("protobuf", "3.22.5") + val protobuf = version("protobuf", "4.28.2") val slf4j = version("slf4j", "2.0.11") library("slf4j-api", "org.slf4j", "slf4j-api").versionRef(slf4j) library("slf4j-jul", "org.slf4j", "jul-to-slf4j").versionRef(slf4j) diff --git a/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaAutoConfig.java b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaAutoConfig.java new file mode 100644 index 00000000000..fccc64443c9 --- /dev/null +++ b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaAutoConfig.java @@ -0,0 +1,59 @@ +// Copyright 2026 The Terasology Foundation +// SPDX-License-Identifier: Apache-2.0 + +package org.terasology.subsystem.nakama; + +import org.terasology.engine.config.flexible.AutoConfig; +import org.terasology.engine.config.flexible.Setting; + +import static org.terasology.engine.config.flexible.SettingArgument.defaultValue; +import static org.terasology.engine.config.flexible.SettingArgument.name; +import static org.terasology.engine.config.flexible.SettingArgument.type; + +/** + * Persistent configuration for the Nakama subsystem. + * Auto-discovered by the engine and saved to ~/.terasology/configs/nakama/NakamaAutoConfig.cfg + */ +public class NakamaAutoConfig extends AutoConfig { + + public final Setting enabled = setting( + type(Boolean.class), + defaultValue(false), + name("Enable Nakama") + ); + + public final Setting host = setting( + type(String.class), + defaultValue("localhost"), + name("Nakama Server Host") + ); + + public final Setting grpcPort = setting( + type(Integer.class), + defaultValue(7349), + name("gRPC Port") + ); + + public final Setting wsPort = setting( + type(Integer.class), + defaultValue(7350), + name("WebSocket Port") + ); + + public final Setting channel = setting( + type(String.class), + defaultValue("bifrost.lobby"), + name("Chat Channel") + ); + + public final Setting playerName = setting( + type(String.class), + defaultValue(""), + name("Player Name") + ); + + @Override + public String getName() { + return "Nakama Settings"; + } +} diff --git a/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java index f47dd08f28a..ec374a06202 100644 --- a/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java +++ b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java @@ -32,7 +32,7 @@ * Optional engine subsystem that bridges Terasology chat to a Nakama * chat channel, enabling cross-game messaging for the Bifrost protocol. * - * Enable via system property: -Dnakama.enabled=true -Dnakama.host=192.168.x.x + * Enable via the Nakama config file at ~/.terasology/configs/nakama/NakamaAutoConfig.cfg */ public class NakamaSubSystem implements EngineSubsystem { private static final Logger logger = LoggerFactory.getLogger(NakamaSubSystem.class); @@ -41,7 +41,7 @@ public class NakamaSubSystem implements EngineSubsystem { "terasology", "TS", "destinationsol", "DS", "minecraft", "MC" ); - private NakamaConfig config; + private NakamaAutoConfig autoConfig; private Client client; private Session session; private SocketClient socket; @@ -60,9 +60,9 @@ public String getName() { @Override public void initialise(GameEngine engine, Context rootContext) { - config = NakamaConfig.fromSystemProperties(); - if (!config.isEnabled()) { - logger.info("Nakama subsystem disabled (set -Dnakama.enabled=true to enable)"); + autoConfig = rootContext.get(NakamaAutoConfig.class); + if (autoConfig == null || !autoConfig.enabled.get()) { + logger.info("Nakama subsystem disabled (enable in Nakama config file)"); return; } connect(); @@ -71,13 +71,11 @@ public void initialise(GameEngine engine, Context rootContext) { private void connect() { try { String deviceId = getOrCreateDeviceId(); - // DefaultClient uses gRPC for API calls (auth, etc.) - client = new DefaultClient("defaultkey", config.getHost(), config.getGrpcPort(), false); + client = new DefaultClient("defaultkey", autoConfig.host.get(), autoConfig.grpcPort.get(), false); session = client.authenticateDevice(deviceId).get(); logger.info("Nakama: authenticated as {}", session.getUserId()); - // createSocket uses WebSocket for realtime — may be a different port via NodePort - socket = client.createSocket(config.getHost(), config.getWsPort(), false); + socket = client.createSocket(autoConfig.host.get(), autoConfig.wsPort.get(), false); socket.connect(session, new AbstractSocketListener() { @Override public void onChannelMessage(ChannelMessage message) { @@ -86,8 +84,8 @@ public void onChannelMessage(ChannelMessage message) { }).get(); // Join the shared chat channel - channel = socket.joinChat(config.getChannel(), ChannelType.ROOM).get(); - logger.info("Nakama: joined channel '{}'", config.getChannel()); + channel = socket.joinChat(autoConfig.channel.get(), ChannelType.ROOM).get(); + logger.info("Nakama: joined channel '{}'", autoConfig.channel.get()); } catch (Exception e) { logger.warn("Nakama: connection failed, continuing without cross-game chat", e); @@ -154,7 +152,7 @@ public void setIncomingMessageHandler(Consumer handler) { @Override public void registerSystems(ComponentSystemManager componentSystemManager) { - if (config != null && config.isEnabled()) { + if (autoConfig != null && autoConfig.enabled.get()) { NakamaSystem nakamaSystem = new NakamaSystem(); nakamaSystem.setNakamaSubSystem(this); componentSystemManager.register(nakamaSystem); @@ -163,7 +161,7 @@ public void registerSystems(ComponentSystemManager componentSystemManager) { @Override public void postInitialise(Context context) { - if (config == null || !config.isEnabled() || !isConnected()) { + if (autoConfig == null || !autoConfig.enabled.get() || !isConnected()) { return; } // Inbound: inject Nakama messages into the local chat system diff --git a/subsystems/Nakama/src/test/java/org/terasology/subsystem/nakama/NakamaConfigTest.java b/subsystems/Nakama/src/test/java/org/terasology/subsystem/nakama/NakamaConfigTest.java index bd3c64b0404..fa85e9680a1 100644 --- a/subsystems/Nakama/src/test/java/org/terasology/subsystem/nakama/NakamaConfigTest.java +++ b/subsystems/Nakama/src/test/java/org/terasology/subsystem/nakama/NakamaConfigTest.java @@ -8,11 +8,13 @@ class NakamaConfigTest { @Test - void defaultsAreDisabled() { - NakamaConfig config = new NakamaConfig(); - assertFalse(config.isEnabled()); - assertEquals("bifrost.lobby", config.getChannel()); - assertEquals(7349, config.getGrpcPort()); - assertEquals(7350, config.getWsPort()); + void autoConfigDefaultsAreDisabled() { + NakamaAutoConfig config = new NakamaAutoConfig(); + assertFalse(config.enabled.get()); + assertEquals("bifrost.lobby", config.channel.get()); + assertEquals(7349, config.grpcPort.get()); + assertEquals(7350, config.wsPort.get()); + assertEquals("localhost", config.host.get()); + assertEquals("", config.playerName.get()); } } From ad8f5dec6296de6212455b4a824e78bcfaa300fa Mon Sep 17 00:00:00 2001 From: Cervator Date: Sun, 29 Mar 2026 00:55:59 -0400 Subject: [PATCH 4/5] feat: show Nakama messages in chat UI and fix player name display Incoming Nakama messages now appear in the in-game chat overlay via Console.addMessage with CoreMessageType.CHAT. Messages are queued on the WebSocket thread and drained on the game thread in preUpdate to avoid threading issues. Console is lazily resolved from the current GameState context. Also fix outbound player name to use DisplayNameComponent instead of the raw EntityRef debug string. Co-Authored-By: Claude Opus 4.6 --- .../subsystem/nakama/NakamaSubSystem.java | 42 +++++++++++++++---- .../subsystem/nakama/NakamaSystem.java | 8 +++- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java index ec374a06202..a1259485e62 100644 --- a/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java +++ b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java @@ -18,7 +18,10 @@ import org.terasology.engine.context.Context; import org.terasology.engine.core.ComponentSystemManager; import org.terasology.engine.core.GameEngine; +import org.terasology.engine.core.modes.GameState; import org.terasology.engine.core.subsystem.EngineSubsystem; +import org.terasology.engine.logic.console.Console; +import org.terasology.engine.logic.console.CoreMessageType; import java.io.IOException; import java.nio.file.Files; @@ -26,6 +29,7 @@ import java.nio.file.Paths; import java.util.Map; import java.util.UUID; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.function.Consumer; /** @@ -47,6 +51,10 @@ public class NakamaSubSystem implements EngineSubsystem { private SocketClient socket; private Channel channel; + // Thread-safe queue for incoming messages — drained on the game thread in preUpdate + private final ConcurrentLinkedQueue incomingMessages = new ConcurrentLinkedQueue<>(); + private Console console; + // Callback for incoming messages — set by the engine/module that handles chat display private Consumer incomingMessageHandler; @@ -61,8 +69,12 @@ public String getName() { @Override public void initialise(GameEngine engine, Context rootContext) { autoConfig = rootContext.get(NakamaAutoConfig.class); - if (autoConfig == null || !autoConfig.enabled.get()) { - logger.info("Nakama subsystem disabled (enable in Nakama config file)"); + if (autoConfig == null) { + logger.warn("Nakama subsystem: NakamaAutoConfig not found in context — AutoConfig discovery may have failed"); + return; + } + if (!autoConfig.enabled.get()) { + logger.info("Nakama subsystem disabled (set enabled=true in config file)"); return; } connect(); @@ -107,6 +119,9 @@ private void handleIncomingMessage(ChannelMessage message) { game.toUpperCase().substring(0, Math.min(game.length(), 2))) + "]"; String formatted = prefix + " " + player + ": " + text; + logger.info("Nakama chat: {}", formatted); + incomingMessages.add(formatted); + if (incomingMessageHandler != null) { suppressOutbound = true; try { @@ -164,12 +179,23 @@ public void postInitialise(Context context) { if (autoConfig == null || !autoConfig.enabled.get() || !isConnected()) { return; } - // Inbound: inject Nakama messages into the local chat system - setIncomingMessageHandler(formatted -> { - // For POC, log to the game log. Full chat injection requires - // accessing the NUI ChatBox or sending a synthetic ChatMessageEvent. - logger.info("Nakama chat: {}", formatted); - }); + console = context.get(Console.class); + } + + @Override + public void preUpdate(GameState currentState, float delta) { + String msg; + while ((msg = incomingMessages.poll()) != null) { + // Lazily resolve console — it's only available once a game world is loaded + if (console == null) { + console = currentState.getContext().get(Console.class); + } + if (console != null) { + console.addMessage(msg, CoreMessageType.CHAT); + } else { + logger.info("Nakama chat (no console yet): {}", msg); + } + } } public boolean isConnected() { diff --git a/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSystem.java b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSystem.java index e9020b2625d..780d760fc21 100644 --- a/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSystem.java +++ b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSystem.java @@ -7,6 +7,7 @@ import org.terasology.engine.entitySystem.systems.BaseComponentSystem; import org.terasology.engine.entitySystem.systems.RegisterSystem; import org.terasology.engine.logic.chat.ChatMessageEvent; +import org.terasology.engine.logic.common.DisplayNameComponent; import org.terasology.engine.network.ClientComponent; import org.terasology.gestalt.entitysystem.event.ReceiveEvent; @@ -25,7 +26,12 @@ public void setNakamaSubSystem(NakamaSubSystem subsystem) { @ReceiveEvent(components = ClientComponent.class) public void onChatMessage(ChatMessageEvent event, EntityRef entity) { if (nakamaSubSystem != null && nakamaSubSystem.isConnected()) { - String playerName = event.getFrom().toString(); + EntityRef from = event.getFrom(); + String playerName = "Unknown"; + DisplayNameComponent displayName = from.getComponent(DisplayNameComponent.class); + if (displayName != null) { + playerName = displayName.name; + } nakamaSubSystem.sendChatMessage(playerName, event.getMessage()); } } From 4a120467e88b88a5ff9f1fb4bf60ee08fa050725 Mon Sep 17 00:00:00 2001 From: Cervator Date: Wed, 1 Apr 2026 14:50:36 -0400 Subject: [PATCH 5/5] A few more tweaks --- .../subsystem/nakama/NakamaSubSystem.java | 60 +++++++++++- .../subsystem/nakama/NakamaSystem.java | 96 ++++++++++++++++++- 2 files changed, 150 insertions(+), 6 deletions(-) diff --git a/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java index a1259485e62..2daa4fdf9e1 100644 --- a/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java +++ b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java @@ -55,6 +55,9 @@ public class NakamaSubSystem implements EngineSubsystem { private final ConcurrentLinkedQueue incomingMessages = new ConcurrentLinkedQueue<>(); private Console console; + // Last received item link — consumed by /materialize + private volatile JsonObject lastItemLink; + // Callback for incoming messages — set by the engine/module that handles chat display private Consumer incomingMessageHandler; @@ -114,12 +117,24 @@ private void handleIncomingMessage(ChannelMessage message) { return; } String player = content.has("player") ? content.get("player").getAsString() : "???"; - String text = content.has("text") ? content.get("text").getAsString() : ""; String prefix = "[" + GAME_PREFIXES.getOrDefault(game, game.toUpperCase().substring(0, Math.min(game.length(), 2))) + "]"; + + // Check for item link message + String type = content.has("type") ? content.get("type").getAsString() : "chat"; + if ("item_link".equals(type)) { + lastItemLink = content; + String itemName = content.has("name") ? content.get("name").getAsString() : "???"; + String formatted = prefix + " " + player + " beamed: [" + itemName + "]"; + logger.info("Nakama RECV item_link (ws thread): {}", formatted); + incomingMessages.add(formatted); + return; + } + + String text = content.has("text") ? content.get("text").getAsString() : ""; String formatted = prefix + " " + player + ": " + text; - logger.info("Nakama chat: {}", formatted); + logger.info("Nakama RECV chat (ws thread): {}", formatted); incomingMessages.add(formatted); if (incomingMessageHandler != null) { @@ -157,6 +172,44 @@ public boolean sendChatMessage(String playerName, String text) { } } + /** + * Send an item link to the Nakama channel. + */ + public boolean sendItemLink(String itemName, String description) { + if (socket == null || channel == null) { + return false; + } + try { + JsonObject content = new JsonObject(); + content.addProperty("game", GAME_ID); + content.addProperty("player", autoConfig.playerName.get()); + content.addProperty("type", "item_link"); + content.addProperty("name", itemName); + content.addProperty("description", description); + socket.writeChatMessage(channel.getId(), content.toString()).get(); + return true; + } catch (Exception e) { + logger.warn("Nakama: failed to send item link", e); + return false; + } + } + + /** + * Consume the last received item link (returns null if none pending). + */ + public JsonObject consumeItemLink() { + JsonObject link = lastItemLink; + lastItemLink = null; + return link; + } + + /** + * Peek at the last received item link without consuming it. + */ + public JsonObject getLastItemLink() { + return lastItemLink; + } + /** * Register a handler for incoming cross-game messages. * The handler receives a pre-formatted string like "[DS] Bob: Hello" @@ -191,9 +244,8 @@ public void preUpdate(GameState currentState, float delta) { console = currentState.getContext().get(Console.class); } if (console != null) { + logger.info("Nakama DISPATCH to console (game thread): {}", msg); console.addMessage(msg, CoreMessageType.CHAT); - } else { - logger.info("Nakama chat (no console yet): {}", msg); } } } diff --git a/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSystem.java b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSystem.java index 780d760fc21..ec667ed9057 100644 --- a/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSystem.java +++ b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSystem.java @@ -3,20 +3,31 @@ package org.terasology.subsystem.nakama; +import com.google.gson.JsonObject; +import org.terasology.engine.entitySystem.entity.EntityManager; import org.terasology.engine.entitySystem.entity.EntityRef; import org.terasology.engine.entitySystem.systems.BaseComponentSystem; import org.terasology.engine.entitySystem.systems.RegisterSystem; import org.terasology.engine.logic.chat.ChatMessageEvent; import org.terasology.engine.logic.common.DisplayNameComponent; +import org.terasology.engine.logic.console.commandSystem.annotations.Command; +import org.terasology.engine.logic.console.commandSystem.annotations.CommandParam; +import org.terasology.engine.logic.console.commandSystem.annotations.Sender; +import org.terasology.engine.logic.inventory.ItemComponent; +import org.terasology.engine.logic.inventory.events.GiveItemEvent; import org.terasology.engine.network.ClientComponent; +import org.terasology.engine.registry.In; import org.terasology.gestalt.entitysystem.event.ReceiveEvent; /** - * Entity system that bridges Gestalt chat events to the NakamaSubSystem. - * Registered via NakamaSubSystem.registerSystems(). + * Entity system that bridges Gestalt chat events to the NakamaSubSystem + * and provides console commands for cross-game item linking. */ @RegisterSystem public class NakamaSystem extends BaseComponentSystem { + @In + private EntityManager entityManager; + private NakamaSubSystem nakamaSubSystem; public void setNakamaSubSystem(NakamaSubSystem subsystem) { @@ -35,4 +46,85 @@ public void onChatMessage(ChatMessageEvent event, EntityRef entity) { nakamaSubSystem.sendChatMessage(playerName, event.getMessage()); } } + + @Command(shortDescription = "Send an item link to the Bifrost channel", + helpText = "Shares an item by name to connected games. Usage: link ") + public String link(@Sender EntityRef sender, @CommandParam("itemName") String itemName) { + if (nakamaSubSystem == null || !nakamaSubSystem.isConnected()) { + return "Nakama not connected"; + } + boolean sent = nakamaSubSystem.sendItemLink(itemName, "Item from Terasology"); + return sent ? "Beamed out: [" + itemName + "]" : "Failed to send item link"; + } + + @Command(shortDescription = "View the last received item link", + helpText = "Shows details of the most recently beamed item from another game.") + public String viewitem(@Sender EntityRef sender) { + if (nakamaSubSystem == null) { + return "Nakama not connected"; + } + JsonObject link = nakamaSubSystem.getLastItemLink(); + if (link == null) { + return "No item link received yet"; + } + String name = link.has("name") ? link.get("name").getAsString() : "???"; + String desc = link.has("description") ? link.get("description").getAsString() : ""; + String game = link.has("game") ? link.get("game").getAsString() : "unknown"; + String player = link.has("player") ? link.get("player").getAsString() : "???"; + float price = link.has("price") ? link.get("price").getAsFloat() : 0; + + StringBuilder sb = new StringBuilder(); + sb.append("=== Beamed Item ===\n"); + sb.append("Name: ").append(name).append("\n"); + sb.append("From: ").append(player).append(" (").append(game).append(")\n"); + if (!desc.isEmpty()) { + sb.append("Desc: ").append(desc).append("\n"); + } + if (price > 0) { + sb.append("Value: ").append(String.format("%.0f", price)).append(" credits"); + } + return sb.toString(); + } + + @Command(shortDescription = "Materialize the last beamed item as a token", + helpText = "Creates a Bifrost Token item in your inventory from the last received item link.", + runOnServer = true) + public String materialize(@Sender EntityRef sender) { + if (nakamaSubSystem == null) { + return "Nakama not connected"; + } + JsonObject link = nakamaSubSystem.consumeItemLink(); + if (link == null) { + return "No item link to materialize"; + } + String name = link.has("name") ? link.get("name").getAsString() : "Bifrost Token"; + String game = link.has("game") ? link.get("game").getAsString() : "unknown"; + String desc = link.has("description") ? link.get("description").getAsString() : ""; + + // Create a proper inventory item entity + EntityRef token = entityManager.create(); + + DisplayNameComponent displayName = new DisplayNameComponent(); + displayName.name = "Bifrost: " + name; + displayName.description = "Beamed from " + game + ". " + desc; + token.addComponent(displayName); + + ItemComponent itemComponent = new ItemComponent(); + itemComponent.stackId = "bifrost:" + name.toLowerCase().replace(' ', '_'); + itemComponent.maxStackSize = 1; + itemComponent.stackCount = 1; + token.addComponent(itemComponent); + + // Give the item to the player's character + EntityRef character = sender.getComponent(ClientComponent.class).character; + GiveItemEvent giveEvent = new GiveItemEvent(character); + token.send(giveEvent); + + if (giveEvent.isHandled()) { + return "Materialized: [" + name + "] from " + game + " — check your inventory!"; + } else { + token.destroy(); + return "Could not materialize [" + name + "] — inventory full?"; + } + } }