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/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/build.gradle.kts b/subsystems/Nakama/build.gradle.kts new file mode 100644 index 00000000000..36df260deff --- /dev/null +++ b/subsystems/Nakama/build.gradle.kts @@ -0,0 +1,45 @@ +// 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") +} + +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/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/NakamaConfig.java b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaConfig.java new file mode 100644 index 00000000000..71ec67bf095 --- /dev/null +++ b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaConfig.java @@ -0,0 +1,59 @@ +// 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 grpcPort = 7349; + private int wsPort = 7350; + 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; } + + /** 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; } + + 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.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 new file mode 100644 index 00000000000..2daa4fdf9e1 --- /dev/null +++ b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSubSystem.java @@ -0,0 +1,296 @@ +// 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.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; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentLinkedQueue; +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 the Nakama config file at ~/.terasology/configs/nakama/NakamaAutoConfig.cfg + */ +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 NakamaAutoConfig autoConfig; + private Client client; + private Session session; + 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; + + // 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; + + // 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) { + autoConfig = rootContext.get(NakamaAutoConfig.class); + 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(); + } + + private void connect() { + try { + String deviceId = getOrCreateDeviceId(); + client = new DefaultClient("defaultkey", autoConfig.host.get(), autoConfig.grpcPort.get(), false); + session = client.authenticateDevice(deviceId).get(); + logger.info("Nakama: authenticated as {}", session.getUserId()); + + socket = client.createSocket(autoConfig.host.get(), autoConfig.wsPort.get(), false); + socket.connect(session, new AbstractSocketListener() { + @Override + public void onChannelMessage(ChannelMessage message) { + handleIncomingMessage(message); + } + }).get(); + + // Join the shared chat channel + 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); + 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 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 RECV chat (ws thread): {}", formatted); + incomingMessages.add(formatted); + + 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; + } + } + + /** + * 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" + */ + public void setIncomingMessageHandler(Consumer handler) { + this.incomingMessageHandler = handler; + } + + @Override + public void registerSystems(ComponentSystemManager componentSystemManager) { + if (autoConfig != null && autoConfig.enabled.get()) { + NakamaSystem nakamaSystem = new NakamaSystem(); + nakamaSystem.setNakamaSubSystem(this); + componentSystemManager.register(nakamaSystem); + } + } + + @Override + public void postInitialise(Context context) { + if (autoConfig == null || !autoConfig.enabled.get() || !isConnected()) { + return; + } + 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) { + logger.info("Nakama DISPATCH to console (game thread): {}", msg); + console.addMessage(msg, CoreMessageType.CHAT); + } + } + } + + 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..ec667ed9057 --- /dev/null +++ b/subsystems/Nakama/src/main/java/org/terasology/subsystem/nakama/NakamaSystem.java @@ -0,0 +1,130 @@ +// Copyright 2026 The Terasology Foundation +// SPDX-License-Identifier: Apache-2.0 + +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 + * 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) { + this.nakamaSubSystem = subsystem; + } + + @ReceiveEvent(components = ClientComponent.class) + public void onChatMessage(ChatMessageEvent event, EntityRef entity) { + if (nakamaSubSystem != null && nakamaSubSystem.isConnected()) { + EntityRef from = event.getFrom(); + String playerName = "Unknown"; + DisplayNameComponent displayName = from.getComponent(DisplayNameComponent.class); + if (displayName != null) { + playerName = displayName.name; + } + 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?"; + } + } +} 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..fa85e9680a1 --- /dev/null +++ b/subsystems/Nakama/src/test/java/org/terasology/subsystem/nakama/NakamaConfigTest.java @@ -0,0 +1,20 @@ +// 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 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()); + } +} 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()); + } +}