From 05675bbafc826e0b73436862cd9896bf3d1d880b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 3 Dec 2025 22:37:53 +0100 Subject: [PATCH 01/20] broadcast module --- nohub/src/broadcast/broadcast.module.ts | 17 +++++++++++++ nohub/src/broadcast/broadcast.service.ts | 31 ++++++++++++++++++++++++ nohub/src/lobbies/lobby.service.ts | 7 +++++- nohub/src/lobbies/lobby.ts | 6 +++-- nohub/src/nohub.ts | 4 +++ nohub/src/sessions/session.ts | 10 +++++++- 6 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 nohub/src/broadcast/broadcast.module.ts create mode 100644 nohub/src/broadcast/broadcast.service.ts diff --git a/nohub/src/broadcast/broadcast.module.ts b/nohub/src/broadcast/broadcast.module.ts new file mode 100644 index 0000000..30a2693 --- /dev/null +++ b/nohub/src/broadcast/broadcast.module.ts @@ -0,0 +1,17 @@ +import type { SessionModule } from "@src/sessions/session.module"; +import { BroadcastService } from "./broadcast.service"; +import type { Module } from "@src/module"; +import type { NohubReactor } from "@src/nohub"; + +export class BroadcastModule implements Module { + readonly broadcastService: BroadcastService; + private reactor?: NohubReactor; + + constructor(sessionModule: SessionModule) { + this.broadcastService = new BroadcastService(() => this.reactor!!, sessionModule.sessionRepository); + } + + configure(reactor: NohubReactor) { + this.reactor = reactor; + } +} diff --git a/nohub/src/broadcast/broadcast.service.ts b/nohub/src/broadcast/broadcast.service.ts new file mode 100644 index 0000000..02ebe6e --- /dev/null +++ b/nohub/src/broadcast/broadcast.service.ts @@ -0,0 +1,31 @@ +import type { CommandSpec, Exchange } from "@foxssake/trimsock-js"; +import { DataNotFoundError } from "@src/errors"; +import type { Lobby } from "@src/lobbies/lobby"; +import type { NohubReactor } from "@src/nohub"; +import type { SessionData, SessionId, SessionSocket } from "@src/sessions/session"; +import type { SessionRepository } from "@src/sessions/session.repository"; + +export class BroadcastService { + constructor (private reactor: () => NohubReactor, private sessionRepository: SessionRepository) {} + + unicast(session: SessionData, command: CommandSpec): Exchange { + if (!session.socket) + throw new DataNotFoundError(`No connection to session#${session.id}!`) // TODO: Probably a more specific exception + + return this.reactor().send(session.socket, command); + } + + broadcast(lobby: Lobby, command: CommandSpec): Map> { + const result = new Map(); + + for (const sessionId of lobby.participants) { + const session = this.sessionRepository.find(sessionId); + if (!session) + continue; // Shouldn't happen, unless lobby participants are not cleared up on client disconnect + + result.set(sessionId, this.unicast(session, command)); + } + + return result; + } +} diff --git a/nohub/src/lobbies/lobby.service.ts b/nohub/src/lobbies/lobby.service.ts index ea1f384..7823537 100644 --- a/nohub/src/lobbies/lobby.service.ts +++ b/nohub/src/lobbies/lobby.service.ts @@ -9,7 +9,7 @@ import { requireLobbyModifiableIn, } from "./lobby"; import type { LobbyEventBus } from "./lobby.events"; -import type { LobbyRepository } from "./lobby.repository"; +import { LobbyRepository } from "./lobby.repository"; export class LobbyService { private logger = rootLogger.child({ name: "LobbyService" }); @@ -65,6 +65,7 @@ export class LobbyService { isVisible: true, isLocked: false, data, + participants: [session.id] }; this.repository.add(lobby); @@ -87,6 +88,10 @@ export class LobbyService { join(lobby: Lobby, session: SessionData): string { requireLobbyJoinable(lobby, session); + lobby.participants.push(session.id); + this.repository.update(lobby); + // TODO: Leave command + // TODO: Listen to session close event to remove session from lobbies return lobby.address; } diff --git a/nohub/src/lobbies/lobby.ts b/nohub/src/lobbies/lobby.ts index 9f010d3..4edfd69 100644 --- a/nohub/src/lobbies/lobby.ts +++ b/nohub/src/lobbies/lobby.ts @@ -1,6 +1,6 @@ import type { CommandSpec } from "@foxssake/trimsock-js"; import { LockedError, UnauthorizedError } from "@src/errors"; -import type { SessionData } from "@src/sessions/session"; +import type { SessionData, SessionId } from "@src/sessions/session"; export interface Lobby { id: string; @@ -10,6 +10,7 @@ export interface Lobby { isVisible: boolean; isLocked: boolean; data: Map; + participants: SessionId[]; } export function requireLobbyModifiableIn( @@ -27,7 +28,7 @@ export function requireLobbyModifiableIn( export function requireLobbyJoinable(lobby: Lobby, session: SessionData) { if (lobby.isLocked) throw new LockedError(`Can't join locked lobby#${lobby.id}!`); - if (lobby.owner === session.id) + if (lobby.owner === session.id || lobby.participants.includes(session.id)) throw new LockedError("Can't join your own lobby - you're already there!"); } @@ -57,6 +58,7 @@ export function commandToLobby(command: CommandSpec): Lobby { gameId: "", address: "", owner: "", + participants: [], }; } diff --git a/nohub/src/nohub.ts b/nohub/src/nohub.ts index 67d7772..3267ed0 100644 --- a/nohub/src/nohub.ts +++ b/nohub/src/nohub.ts @@ -10,6 +10,7 @@ import { MetricsModule } from "./metrics/metrics.module"; import type { Module } from "./module"; import type { SessionData } from "./sessions/session"; import { SessionModule } from "./sessions/session.module"; +import { BroadcastModule } from "./broadcast/broadcast.module"; export type NohubReactor = BunSocketReactor; @@ -19,6 +20,7 @@ export class NohubModules { readonly gameModule: GameModule; readonly lobbyModule: LobbyModule; readonly sessionModule: SessionModule; + readonly broadcastModule: BroadcastModule; readonly all: Module[]; @@ -37,12 +39,14 @@ export class NohubModules { config.sessions, this.metricsModule.metricsHolder, ); + this.broadcastModule = new BroadcastModule(this.sessionModule); this.all = [ this.metricsModule, this.gameModule, this.lobbyModule, this.sessionModule, + this.broadcastModule ]; } } diff --git a/nohub/src/sessions/session.ts b/nohub/src/sessions/session.ts index ec0a2e2..c4e11ad 100644 --- a/nohub/src/sessions/session.ts +++ b/nohub/src/sessions/session.ts @@ -1,5 +1,13 @@ +import type { Socket } from "bun"; + +export type SessionSocket = Socket; + +export type SessionId = string; + export interface SessionData { - id: string; + id: SessionId; gameId?: string; address: string; + + socket?: SessionSocket; } From d841379ad39d3cb6c6d1f77128d1145890dd6b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 3 Dec 2025 22:49:13 +0100 Subject: [PATCH 02/20] signaling module --- nohub/src/nohub.ts | 7 ++++- nohub/src/sessions/session.api.ts | 1 + nohub/src/signaling/signaling.module.ts | 35 +++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 nohub/src/signaling/signaling.module.ts diff --git a/nohub/src/nohub.ts b/nohub/src/nohub.ts index 3267ed0..00ac54a 100644 --- a/nohub/src/nohub.ts +++ b/nohub/src/nohub.ts @@ -11,6 +11,7 @@ import type { Module } from "./module"; import type { SessionData } from "./sessions/session"; import { SessionModule } from "./sessions/session.module"; import { BroadcastModule } from "./broadcast/broadcast.module"; +import { SignalingModule } from "./signaling/signaling.module"; export type NohubReactor = BunSocketReactor; @@ -21,6 +22,7 @@ export class NohubModules { readonly lobbyModule: LobbyModule; readonly sessionModule: SessionModule; readonly broadcastModule: BroadcastModule; + readonly signalingModule: SignalingModule; readonly all: Module[]; @@ -40,13 +42,15 @@ export class NohubModules { this.metricsModule.metricsHolder, ); this.broadcastModule = new BroadcastModule(this.sessionModule); + this.signalingModule = new SignalingModule(this.lobbyModule, this.broadcastModule); this.all = [ this.metricsModule, this.gameModule, this.lobbyModule, this.sessionModule, - this.broadcastModule + this.broadcastModule, + this.signalingModule ]; } } @@ -151,6 +155,7 @@ export class Nohub { rootLogger.info("Attaching %d modules...", modules.length); modules.forEach((it) => { + rootLogger.info("Attaching module %s...", it.constructor?.name) it.attachTo?.(this); this.reactor && it.configure && it.configure(this.reactor); }); diff --git a/nohub/src/sessions/session.api.ts b/nohub/src/sessions/session.api.ts index 9fa3e6e..37859a6 100644 --- a/nohub/src/sessions/session.api.ts +++ b/nohub/src/sessions/session.api.ts @@ -55,6 +55,7 @@ export class SessionApi { id: this.generateSessionId(), gameId: this.config.defaultGameId, address, + socket }; this.sessionRepository.add(session); diff --git a/nohub/src/signaling/signaling.module.ts b/nohub/src/signaling/signaling.module.ts new file mode 100644 index 0000000..f852e50 --- /dev/null +++ b/nohub/src/signaling/signaling.module.ts @@ -0,0 +1,35 @@ +import type { BroadcastModule } from "@src/broadcast/broadcast.module"; +import type { BroadcastService } from "@src/broadcast/broadcast.service"; +import type { LobbyModule } from "@src/lobbies/lobby.module"; +import type { LobbyRepository } from "@src/lobbies/lobby.repository"; +import type { Module } from "@src/module"; +import type { NohubReactor } from "@src/nohub"; +import { sessionOf } from "@src/sessions/session.api"; +import { requireSingleParam } from "@src/validators"; + +// This is an example module to demonstrate how to inject the BroadcastService +// into custom modules and classes + +export class SignalingModule implements Module { + private readonly broadcastService: BroadcastService; + private readonly lobbyRepository: LobbyRepository; + + constructor( + lobbyModule: LobbyModule, + broadcastModule: BroadcastModule + ) { + this.broadcastService = broadcastModule.broadcastService + this.lobbyRepository = lobbyModule.lobbyRepository + } + + configure(reactor: NohubReactor) { + reactor.on("signaling/greet/lobby", (cmd, xchg) => { + const lobbyId = requireSingleParam(cmd, "Missing lobby id!"); + + const session = sessionOf(xchg); + const lobby = this.lobbyRepository.requireInGame(lobbyId, session.gameId); + + this.broadcastService.broadcast(lobby, { name: "signaling/greet", text: "Hi!" }) + }) + } +} From cb4b572b4851fb2f183618b5eb748b38bded3c44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 3 Dec 2025 22:51:05 +0100 Subject: [PATCH 03/20] fmt --- nohub/src/broadcast/broadcast.module.ts | 14 +++++++++++--- nohub/src/broadcast/broadcast.service.ts | 23 ++++++++++++++++------- nohub/src/lobbies/lobby.service.ts | 4 ++-- nohub/src/nohub.ts | 11 +++++++---- nohub/src/sessions/session.api.ts | 2 +- nohub/src/signaling/signaling.module.ts | 18 +++++++++--------- 6 files changed, 46 insertions(+), 26 deletions(-) diff --git a/nohub/src/broadcast/broadcast.module.ts b/nohub/src/broadcast/broadcast.module.ts index 30a2693..2b7dcad 100644 --- a/nohub/src/broadcast/broadcast.module.ts +++ b/nohub/src/broadcast/broadcast.module.ts @@ -1,17 +1,25 @@ -import type { SessionModule } from "@src/sessions/session.module"; -import { BroadcastService } from "./broadcast.service"; import type { Module } from "@src/module"; import type { NohubReactor } from "@src/nohub"; +import type { SessionModule } from "@src/sessions/session.module"; +import { BroadcastService } from "./broadcast.service"; export class BroadcastModule implements Module { readonly broadcastService: BroadcastService; private reactor?: NohubReactor; constructor(sessionModule: SessionModule) { - this.broadcastService = new BroadcastService(() => this.reactor!!, sessionModule.sessionRepository); + this.broadcastService = new BroadcastService( + this.provideReactor, + sessionModule.sessionRepository, + ); } configure(reactor: NohubReactor) { this.reactor = reactor; } + + private provideReactor(): NohubReactor { + if (!this.reactor) throw new Error("Missing Reactor instance!"); + return this.reactor; + } } diff --git a/nohub/src/broadcast/broadcast.service.ts b/nohub/src/broadcast/broadcast.service.ts index 02ebe6e..07ba858 100644 --- a/nohub/src/broadcast/broadcast.service.ts +++ b/nohub/src/broadcast/broadcast.service.ts @@ -2,27 +2,36 @@ import type { CommandSpec, Exchange } from "@foxssake/trimsock-js"; import { DataNotFoundError } from "@src/errors"; import type { Lobby } from "@src/lobbies/lobby"; import type { NohubReactor } from "@src/nohub"; -import type { SessionData, SessionId, SessionSocket } from "@src/sessions/session"; +import type { + SessionData, + SessionId, + SessionSocket, +} from "@src/sessions/session"; import type { SessionRepository } from "@src/sessions/session.repository"; export class BroadcastService { - constructor (private reactor: () => NohubReactor, private sessionRepository: SessionRepository) {} + constructor( + private reactor: () => NohubReactor, + private sessionRepository: SessionRepository, + ) {} unicast(session: SessionData, command: CommandSpec): Exchange { if (!session.socket) - throw new DataNotFoundError(`No connection to session#${session.id}!`) // TODO: Probably a more specific exception + throw new DataNotFoundError(`No connection to session#${session.id}!`); // TODO: Probably a more specific exception return this.reactor().send(session.socket, command); } - broadcast(lobby: Lobby, command: CommandSpec): Map> { + broadcast( + lobby: Lobby, + command: CommandSpec, + ): Map> { const result = new Map(); for (const sessionId of lobby.participants) { const session = this.sessionRepository.find(sessionId); - if (!session) - continue; // Shouldn't happen, unless lobby participants are not cleared up on client disconnect - + if (!session) continue; // Shouldn't happen, unless lobby participants are not cleared up on client disconnect + result.set(sessionId, this.unicast(session, command)); } diff --git a/nohub/src/lobbies/lobby.service.ts b/nohub/src/lobbies/lobby.service.ts index 7823537..5356ea9 100644 --- a/nohub/src/lobbies/lobby.service.ts +++ b/nohub/src/lobbies/lobby.service.ts @@ -9,7 +9,7 @@ import { requireLobbyModifiableIn, } from "./lobby"; import type { LobbyEventBus } from "./lobby.events"; -import { LobbyRepository } from "./lobby.repository"; +import type { LobbyRepository } from "./lobby.repository"; export class LobbyService { private logger = rootLogger.child({ name: "LobbyService" }); @@ -65,7 +65,7 @@ export class LobbyService { isVisible: true, isLocked: false, data, - participants: [session.id] + participants: [session.id], }; this.repository.add(lobby); diff --git a/nohub/src/nohub.ts b/nohub/src/nohub.ts index 00ac54a..1ea80c5 100644 --- a/nohub/src/nohub.ts +++ b/nohub/src/nohub.ts @@ -2,6 +2,7 @@ import { BunSocketReactor } from "@foxssake/trimsock-bun"; import { Command, TrimsockReader } from "@foxssake/trimsock-js"; import type { AppConfig } from "@src/config"; import { rootLogger } from "@src/logger"; +import { BroadcastModule } from "./broadcast/broadcast.module"; import { UnknownCommandError } from "./errors"; import { NohubEventBus } from "./events"; import { GameModule } from "./games/game.module"; @@ -10,7 +11,6 @@ import { MetricsModule } from "./metrics/metrics.module"; import type { Module } from "./module"; import type { SessionData } from "./sessions/session"; import { SessionModule } from "./sessions/session.module"; -import { BroadcastModule } from "./broadcast/broadcast.module"; import { SignalingModule } from "./signaling/signaling.module"; export type NohubReactor = BunSocketReactor; @@ -42,7 +42,10 @@ export class NohubModules { this.metricsModule.metricsHolder, ); this.broadcastModule = new BroadcastModule(this.sessionModule); - this.signalingModule = new SignalingModule(this.lobbyModule, this.broadcastModule); + this.signalingModule = new SignalingModule( + this.lobbyModule, + this.broadcastModule, + ); this.all = [ this.metricsModule, @@ -50,7 +53,7 @@ export class NohubModules { this.lobbyModule, this.sessionModule, this.broadcastModule, - this.signalingModule + this.signalingModule, ]; } } @@ -155,7 +158,7 @@ export class Nohub { rootLogger.info("Attaching %d modules...", modules.length); modules.forEach((it) => { - rootLogger.info("Attaching module %s...", it.constructor?.name) + rootLogger.info("Attaching module %s...", it.constructor?.name); it.attachTo?.(this); this.reactor && it.configure && it.configure(this.reactor); }); diff --git a/nohub/src/sessions/session.api.ts b/nohub/src/sessions/session.api.ts index 37859a6..b074447 100644 --- a/nohub/src/sessions/session.api.ts +++ b/nohub/src/sessions/session.api.ts @@ -55,7 +55,7 @@ export class SessionApi { id: this.generateSessionId(), gameId: this.config.defaultGameId, address, - socket + socket, }; this.sessionRepository.add(session); diff --git a/nohub/src/signaling/signaling.module.ts b/nohub/src/signaling/signaling.module.ts index f852e50..53507de 100644 --- a/nohub/src/signaling/signaling.module.ts +++ b/nohub/src/signaling/signaling.module.ts @@ -11,15 +11,12 @@ import { requireSingleParam } from "@src/validators"; // into custom modules and classes export class SignalingModule implements Module { - private readonly broadcastService: BroadcastService; + private readonly broadcastService: BroadcastService; private readonly lobbyRepository: LobbyRepository; - constructor( - lobbyModule: LobbyModule, - broadcastModule: BroadcastModule - ) { - this.broadcastService = broadcastModule.broadcastService - this.lobbyRepository = lobbyModule.lobbyRepository + constructor(lobbyModule: LobbyModule, broadcastModule: BroadcastModule) { + this.broadcastService = broadcastModule.broadcastService; + this.lobbyRepository = lobbyModule.lobbyRepository; } configure(reactor: NohubReactor) { @@ -29,7 +26,10 @@ export class SignalingModule implements Module { const session = sessionOf(xchg); const lobby = this.lobbyRepository.requireInGame(lobbyId, session.gameId); - this.broadcastService.broadcast(lobby, { name: "signaling/greet", text: "Hi!" }) - }) + this.broadcastService.broadcast(lobby, { + name: "signaling/greet", + text: "Hi!", + }); + }); } } From 8cfc87ccd7a3e8df7f5df729c0b19214f08e470b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Fri, 5 Dec 2025 10:50:06 +0100 Subject: [PATCH 04/20] fix reactor() --- nohub/src/broadcast/broadcast.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nohub/src/broadcast/broadcast.module.ts b/nohub/src/broadcast/broadcast.module.ts index 2b7dcad..15b0cee 100644 --- a/nohub/src/broadcast/broadcast.module.ts +++ b/nohub/src/broadcast/broadcast.module.ts @@ -9,7 +9,7 @@ export class BroadcastModule implements Module { constructor(sessionModule: SessionModule) { this.broadcastService = new BroadcastService( - this.provideReactor, + () => this.provideReactor(), sessionModule.sessionRepository, ); } From 1400b8c0a0803fa5162358f2feb210ee63865570 Mon Sep 17 00:00:00 2001 From: Andrew Davis Date: Thu, 25 Dec 2025 14:38:06 -0500 Subject: [PATCH 05/20] feat: webrtc setup reactor and signaling module --- nohub.gd/addons/nohub.gd/nohub_client.gd | 145 +++++++++++++++++++++-- nohub.gd/addons/nohub.gd/result.gd | 18 +++ nohub/src/broadcast/broadcast.service.ts | 9 +- nohub/src/sessions/session.api.ts | 9 +- nohub/src/sessions/session.module.ts | 3 + nohub/src/signaling/signaling.module.ts | 81 +++++++++++-- 6 files changed, 239 insertions(+), 26 deletions(-) diff --git a/nohub.gd/addons/nohub.gd/nohub_client.gd b/nohub.gd/addons/nohub.gd/nohub_client.gd index 9efa88e..215a522 100644 --- a/nohub.gd/addons/nohub.gd/nohub_client.gd +++ b/nohub.gd/addons/nohub.gd/nohub_client.gd @@ -37,6 +37,7 @@ func _init(connection: StreamPeerTCP): _connection.set_no_delay(true) _reactor = TrimsockTCPClientReactor.new(connection) + _setup_webrtc_reactor() ## Poll the client ## [br][br] @@ -48,13 +49,13 @@ func poll() -> void: ## [br][br] ## See [url=https://foxssake.github.io/nohub/understanding-nohub/concepts.html#games]Games[/url]. func set_game(id: String) -> NohubResult: - var request := TrimsockCommand.request("session/set-game")\ + var request := TrimsockCommand.request("session/set-game") \ .with_params([id]) return await _bool_request(request) ## Create a lobby func create_lobby(address: String, data: Dictionary = {}) -> NohubResult.Lobby: - var request := TrimsockCommand.request("lobby/create")\ + var request := TrimsockCommand.request("lobby/create") \ .with_params([address]) for key in data: request.with_kv_pairs([TrimsockCommand.pair_of(key, data[key])]) @@ -72,7 +73,7 @@ func create_lobby(address: String, data: Dictionary = {}) -> NohubResult.Lobby: ## If [param properties] is specified, only the listed properties will be ## returned from the lobby's custom data. func get_lobby(id: String, properties: Array[String] = []) -> NohubResult.Lobby: - var request := TrimsockCommand.request("lobby/get")\ + var request := TrimsockCommand.request("lobby/get") \ .with_params([id] + properties) var xchg := _reactor.submit_request(request) var response := await xchg.read() @@ -88,7 +89,7 @@ func get_lobby(id: String, properties: Array[String] = []) -> NohubResult.Lobby: ## returned from the lobby's custom data. func list_lobbies(properties: Array[String] = []) -> NohubResult.LobbyList: var result := [] as Array[NohubLobby] - var request := TrimsockCommand.request("lobby/list")\ + var request := TrimsockCommand.request("lobby/list") \ .with_params(properties) var xchg := _reactor.submit_request(request) @@ -108,7 +109,7 @@ func list_lobbies(properties: Array[String] = []) -> NohubResult.LobbyList: ## [br][br] ## Only the lobby's owner can delete the lobby. func delete_lobby(lobby_id: String) -> NohubResult: - var request := TrimsockCommand.request("lobby/delete")\ + var request := TrimsockCommand.request("lobby/delete") \ .with_params([lobby_id]) return await _bool_request(request) @@ -117,7 +118,7 @@ func delete_lobby(lobby_id: String) -> NohubResult: ## The response will contain the lobby's address. This string can be used to ## connect. func join_lobby(lobby_id: String) -> NohubResult.Address: - var request := TrimsockCommand.request("lobby/join")\ + var request := TrimsockCommand.request("lobby/join") \ .with_params([lobby_id]) var xchg := _reactor.submit_request(request) @@ -132,7 +133,7 @@ func join_lobby(lobby_id: String) -> NohubResult.Address: ## [br][br] ## Only the lobby's owner can lock the lobby. func lock_lobby(lobby_id: String) -> NohubResult: - var request := TrimsockCommand.request("lobby/lock")\ + var request := TrimsockCommand.request("lobby/lock") \ .with_params([lobby_id]) return await _bool_request(request) @@ -140,7 +141,7 @@ func lock_lobby(lobby_id: String) -> NohubResult: ## [br][br] ## Only the lobby's owner can unlock the lobby. Lobbies are unlocked by default. func unlock_lobby(lobby_id: String) -> NohubResult: - var request := TrimsockCommand.request("lobby/unlock")\ + var request := TrimsockCommand.request("lobby/unlock") \ .with_params([lobby_id]) return await _bool_request(request) @@ -148,7 +149,7 @@ func unlock_lobby(lobby_id: String) -> NohubResult: ## [br][br] ## Only the lobby's owner can hide the lobby. func hide_lobby(lobby_id: String) -> NohubResult: - var request := TrimsockCommand.request("lobby/hide")\ + var request := TrimsockCommand.request("lobby/hide") \ .with_params([lobby_id]) return await _bool_request(request) @@ -156,7 +157,7 @@ func hide_lobby(lobby_id: String) -> NohubResult: ## [br][br] ## Only the lobby's owner can hide the lobby. Lobbies are visible by default. func publish_lobby(lobby_id: String) -> NohubResult: - var request := TrimsockCommand.request("lobby/publish")\ + var request := TrimsockCommand.request("lobby/publish") \ .with_params([lobby_id]) return await _bool_request(request) @@ -165,8 +166,8 @@ func publish_lobby(lobby_id: String) -> NohubResult: ## Note that this method updates the data, instead of adding to it. Only the ## lobby's owner can update the lobby's custom data. func set_lobby_data(lobby_id: String, data: Dictionary) -> NohubResult: - var request := TrimsockCommand.request("lobby/set-data")\ - .with_params([lobby_id])\ + var request := TrimsockCommand.request("lobby/set-data") \ + .with_params([lobby_id]) \ .with_kv_map(data) return await _bool_request(request) @@ -207,3 +208,123 @@ func _command_to_error(command: TrimsockCommand) -> NohubResult: return NohubResult.of_error(command.params[0], command.params[1]) else: return NohubResult.of_error(command.name, "") + +#region WebRTC + +# TODO: Determine the best place to define these signals. They are used in the browser +signal signal_webrtc_create_new_peer_connection(id) +signal signal_webrtc_message(type, data) + +## Setup the events for negotiating WebRTC connections using the signalling module +## [br][br] +## When a lobby host starts a lobby (or a peer asks for an offer, answer, or candidate): +## these events will emit signals that the clients use to initialize WebRTC peer connections +## NOTE: This function is based on the _setup_reactor example in this file: +## https://github.com/foxssake/trimsock/blob/main/trimsock.gd/examples/server/server.gd +func _setup_webrtc_reactor() -> void: + _reactor.on("webrtc/start", func(_cmd: TrimsockCommand, xchg: TrimsockExchange): + var players = _cmd.kv_map['players'] as String + for peer_id in players.split(',', false): + signal_webrtc_create_new_peer_connection.emit(int(peer_id.strip_edges())) + ).on("webrtc/get/offer", func(_cmd, xchg: TrimsockExchange): + signal_webrtc_message.emit(ACTION.Offer, _cmd) + ).on("webrtc/get/answer", func(_cmd, xchg: TrimsockExchange): + signal_webrtc_message.emit(ACTION.Answer, _cmd) + ).on("webrtc/get/candidate", func(_cmd, xchg: TrimsockExchange): + signal_webrtc_message.emit(ACTION.Candidate, _cmd) + ).on_unknown(func(cmd, xchg: TrimsockExchange): + _log("[srv] Unknown command: %s" % cmd) + return TrimsockCommand.error_from(cmd, "error", ["Unknown command", cmd.name]) + ) + + # TODO: Necessary? + _reactor.on_attach.connect(func(src: StreamPeerTCP): + var id := _session_id() + _log("[srv] New connection: " + id) + src.set_no_delay(true) + + _reactor.set_session(src, id) + _reactor.send(src, TrimsockCommand.simple("ohai")) + ) + + _reactor.on_detach.connect(func(src): + _log("[srv] Connection closed!") + ) + +func _log(what: String) -> void: + prints(what) + +func _session_id(length: int = 4) -> String: + const charset := "abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "0123456789" + var id := "" + for i in length: + id += charset[randi() % charset.length()] + return id + + +## Start lobby, kicking off joining +## [br][br] +## Only the lobby's owner can start the lobby. +func start_lobby(lobby_id: String) -> NohubResult.LobbyMessage: + var request := TrimsockCommand.request("signal/start/lobby") \ + .with_params([lobby_id]) + + var xchg := _reactor.submit_request(request) + var response := await xchg.read() + + if response.is_success(): + return NohubResult.LobbyMessage.of_value(response.params[0]) + else: + return _command_to_error(response) + +## Leave the lobby +## [br][br] +func leave_lobby(lobby_id: String) -> NohubResult.LobbyMessage: + var request := TrimsockCommand.request("lobby/leave") \ + .with_params([lobby_id]) + + var xchg := _reactor.submit_request(request) + var response := await xchg.read() + + if response.is_success(): + return NohubResult.LobbyMessage.of_value(response.params[0]) + else: + return _command_to_error(response) + +func get_session() -> String: + var request := TrimsockCommand.request("getid") + var xchg := _reactor.submit_request(request) + var response := await xchg.read() + if response.is_success(): + return response.text + else: + return "" + +enum ACTION { + Offer, + Answer, + Candidate +} + +func send_webrtc_message(type: ACTION, id: String, data: Dictionary = {}) -> NohubResult: + var request + + match type: + ACTION.Offer: + request = TrimsockCommand.request("signal/offer").with_params([id]) + ACTION.Answer: + request = TrimsockCommand.request("signal/answer").with_params([id]) + ACTION.Candidate: + request = TrimsockCommand.request("signal/candidate").with_params([id]) + + request.with_kv_map(data) + + var xchg := _reactor.submit_request(request) + var response := await xchg.read() + + if response.is_success(): + return NohubResult.LobbyMessage.of_value('send success') + else: + return _command_to_error(response) + +#endregion \ No newline at end of file diff --git a/nohub.gd/addons/nohub.gd/result.gd b/nohub.gd/addons/nohub.gd/result.gd index 40f909e..13f141a 100644 --- a/nohub.gd/addons/nohub.gd/result.gd +++ b/nohub.gd/addons/nohub.gd/result.gd @@ -69,6 +69,24 @@ class LobbyList extends NohubResult: else: return [] +## Stores a LobbyMessage on success +## +## See [NohubResult] for details. +class LobbyMessage extends NohubResult: + ## Construct a successful result object with the given [param value] + static func of_value(value: String) -> LobbyMessage: + var result := LobbyMessage.new() + result._is_success = true + result._value = value + return result + + ## Get the resulting string + func value() -> String: + if _is_success: + return _value as String + else: + return "" + ## Stores an address string on success ## ## See [NohubResult] for details. diff --git a/nohub/src/broadcast/broadcast.service.ts b/nohub/src/broadcast/broadcast.service.ts index 07ba858..6477f37 100644 --- a/nohub/src/broadcast/broadcast.service.ts +++ b/nohub/src/broadcast/broadcast.service.ts @@ -15,9 +15,10 @@ export class BroadcastService { private sessionRepository: SessionRepository, ) {} - unicast(session: SessionData, command: CommandSpec): Exchange { - if (!session.socket) - throw new DataNotFoundError(`No connection to session#${session.id}!`); // TODO: Probably a more specific exception + unicast(sessionId: string, command: CommandSpec): Exchange { + const session = this.sessionRepository.find(sessionId); + if (!session?.socket) + throw new DataNotFoundError(`No connection to session#${session?.id}!`); // TODO: Probably a more specific exception return this.reactor().send(session.socket, command); } @@ -32,7 +33,7 @@ export class BroadcastService { const session = this.sessionRepository.find(sessionId); if (!session) continue; // Shouldn't happen, unless lobby participants are not cleared up on client disconnect - result.set(sessionId, this.unicast(session, command)); + result.set(sessionId, this.unicast(sessionId, command)); } return result; diff --git a/nohub/src/sessions/session.api.ts b/nohub/src/sessions/session.api.ts index b074447..1a6298b 100644 --- a/nohub/src/sessions/session.api.ts +++ b/nohub/src/sessions/session.api.ts @@ -7,7 +7,8 @@ import type { LobbyLookup } from "@src/lobbies/lobby.repository"; import { rootLogger } from "@src/logger"; import { emptyMetrics, type MetricsHolder } from "@src/metrics/metrics"; import type { Socket } from "bun"; -import { nanoid } from "nanoid"; +// import { nanoid } from "nanoid"; +import * as crypto from "crypto"; import type { SessionData } from "./session"; import type { SessionRepository } from "./session.repository"; @@ -23,8 +24,12 @@ export class SessionApi { private metrics: MetricsHolder = emptyMetrics, ) {} + // generateSessionId(): string { + // return nanoid(this.config.idLength); + // } + generateSessionId(): string { - return nanoid(this.config.idLength); + return Math.abs(new Int32Array(crypto.randomBytes(4).buffer)[0]).toString(); } openSession(socket: Socket): void { diff --git a/nohub/src/sessions/session.module.ts b/nohub/src/sessions/session.module.ts index 9e4805a..f9b21ac 100644 --- a/nohub/src/sessions/session.module.ts +++ b/nohub/src/sessions/session.module.ts @@ -49,6 +49,9 @@ export class SessionModule implements Module { name: "youarehere", params: [xchg.source.remoteAddress], }); + }) + .on("getid", (_cmd, xchg) => { + xchg.reply({ text: xchg.source.data.id }); }); } diff --git a/nohub/src/signaling/signaling.module.ts b/nohub/src/signaling/signaling.module.ts index 53507de..ba482d9 100644 --- a/nohub/src/signaling/signaling.module.ts +++ b/nohub/src/signaling/signaling.module.ts @@ -1,3 +1,4 @@ +import { rootLogger } from "@src/logger"; import type { BroadcastModule } from "@src/broadcast/broadcast.module"; import type { BroadcastService } from "@src/broadcast/broadcast.service"; import type { LobbyModule } from "@src/lobbies/lobby.module"; @@ -13,6 +14,7 @@ import { requireSingleParam } from "@src/validators"; export class SignalingModule implements Module { private readonly broadcastService: BroadcastService; private readonly lobbyRepository: LobbyRepository; + private logger = rootLogger.child({ name: "SignalingApi" }); constructor(lobbyModule: LobbyModule, broadcastModule: BroadcastModule) { this.broadcastService = broadcastModule.broadcastService; @@ -20,16 +22,79 @@ export class SignalingModule implements Module { } configure(reactor: NohubReactor) { - reactor.on("signaling/greet/lobby", (cmd, xchg) => { - const lobbyId = requireSingleParam(cmd, "Missing lobby id!"); + reactor + .on("signaling/greet/lobby", (cmd, xchg) => { + const lobbyId = requireSingleParam(cmd, "Missing lobby id!"); - const session = sessionOf(xchg); - const lobby = this.lobbyRepository.requireInGame(lobbyId, session.gameId); + const session = sessionOf(xchg); + const lobby = this.lobbyRepository.requireInGame( + lobbyId, + session.gameId, + ); - this.broadcastService.broadcast(lobby, { - name: "signaling/greet", - text: "Hi!", + this.broadcastService.broadcast(lobby, { + name: "signaling/greet", + text: "Hi!", + }); + }) + + .on("signal/start/lobby", (cmd, xchg) => { + const lobbyId = requireSingleParam(cmd, "Missing lobby id!"); + + const session = sessionOf(xchg); + const lobby = this.lobbyRepository.requireInGame( + lobbyId, + session.gameId, + ); + + const payload = new Map() as Map; + payload.set("host", lobby.participants[0]); + payload.set("lobbyId", lobbyId); + payload.set("players", lobby.participants.join(", ")); + + // Sends to all lobby participants, they will begin connections with each id. + this.broadcastService.broadcast(lobby, { + name: "signal/start", + kvParams: [...payload.entries()], + }); + + this.logger.info({ lobbyId }, "Starting lobby #%s", lobbyId); + + // TODO: This confirms back to leader we started, but what about tracking all inprogress xchanges kicked off? + xchg.reply({ text: lobby.participants.join(",") }); + }) + .on("signal/offer", (cmd, xchg) => { + const sessionId = requireSingleParam(cmd, "Missing session id!"); + this.broadcastService.unicast(sessionId, { + name: "signal/get/offer", + kvMap: cmd.kvMap, + }); + + // TODO: xchang reply to garuntee connections? Currently these just blast off, no await either side. + // But this process is really a handshake event. Could also throw errors for example: to indicate we need TURN. + // TODO: Handle fail cases: Somone drops + // TODO: Start some promises await all some how before sending the reply. + + // xchg.reply?? + }) + .on("signal/answer", (cmd, xchg) => { + const sessionId = requireSingleParam(cmd, "Missing session id!"); + this.broadcastService.unicast(sessionId, { + name: "signal/get/answer", + kvMap: cmd.kvMap, + }); + }) + .on("signal/candidate", (cmd, xchg) => { + const sessionId = requireSingleParam(cmd, "Missing session id!"); + this.logger.info( + { sessionId }, + "Confirming candidate for #%s", + sessionId, + ); + this.broadcastService.unicast(sessionId, { + name: "signal/get/candidate", + kvMap: cmd.kvMap, + }); }); - }); } } From 65f17e2aa8344cb2035d817b7ab612d7772ce7db Mon Sep 17 00:00:00 2001 From: Andrew Davis Date: Thu, 25 Dec 2025 14:43:04 -0500 Subject: [PATCH 06/20] chore: improve documentation and comments in signaling module --- nohub/src/signaling/signaling.module.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/nohub/src/signaling/signaling.module.ts b/nohub/src/signaling/signaling.module.ts index ba482d9..47bfad9 100644 --- a/nohub/src/signaling/signaling.module.ts +++ b/nohub/src/signaling/signaling.module.ts @@ -52,7 +52,7 @@ export class SignalingModule implements Module { payload.set("lobbyId", lobbyId); payload.set("players", lobby.participants.join(", ")); - // Sends to all lobby participants, they will begin connections with each id. + // Sends to all lobby participants, they will begin WebRTC connections with each peer by sessionId. this.broadcastService.broadcast(lobby, { name: "signal/start", kvParams: [...payload.entries()], @@ -60,22 +60,21 @@ export class SignalingModule implements Module { this.logger.info({ lobbyId }, "Starting lobby #%s", lobbyId); - // TODO: This confirms back to leader we started, but what about tracking all inprogress xchanges kicked off? + // Reply back to lobby host that we started. + // TODO: Are we able to listen for the results each one of the broadcasts? xchg.reply({ text: lobby.participants.join(",") }); }) .on("signal/offer", (cmd, xchg) => { const sessionId = requireSingleParam(cmd, "Missing session id!"); + this.broadcastService.unicast(sessionId, { name: "signal/get/offer", kvMap: cmd.kvMap, }); - // TODO: xchang reply to garuntee connections? Currently these just blast off, no await either side. - // But this process is really a handshake event. Could also throw errors for example: to indicate we need TURN. - // TODO: Handle fail cases: Somone drops - // TODO: Start some promises await all some how before sending the reply. - - // xchg.reply?? + // TODO: Sending an offer and getting one back could be something we could use the xchg for + // TODO: Handle errors at each step, for example: If STUN is not enough, indicate we need TURN. + // xchg.reply }) .on("signal/answer", (cmd, xchg) => { const sessionId = requireSingleParam(cmd, "Missing session id!"); From b5dc7402346aa7968389720a4b29916588d58711 Mon Sep 17 00:00:00 2001 From: Andrew Davis Date: Thu, 25 Dec 2025 22:37:31 -0500 Subject: [PATCH 07/20] feat: add examples folder and browser_webrtc.tscn --- nohub.gd/addons/nohub.gd/nohub_client.gd | 26 +- nohub.gd/examples/webrtc/README.md | 4 + nohub.gd/examples/webrtc/browser_webrtc.tscn | 703 +++++++++++++++++++ nohub/src/signaling/signaling.module.ts | 36 +- 4 files changed, 731 insertions(+), 38 deletions(-) create mode 100644 nohub.gd/examples/webrtc/README.md create mode 100644 nohub.gd/examples/webrtc/browser_webrtc.tscn diff --git a/nohub.gd/addons/nohub.gd/nohub_client.gd b/nohub.gd/addons/nohub.gd/nohub_client.gd index 215a522..56d0121 100644 --- a/nohub.gd/addons/nohub.gd/nohub_client.gd +++ b/nohub.gd/addons/nohub.gd/nohub_client.gd @@ -227,11 +227,11 @@ func _setup_webrtc_reactor() -> void: for peer_id in players.split(',', false): signal_webrtc_create_new_peer_connection.emit(int(peer_id.strip_edges())) ).on("webrtc/get/offer", func(_cmd, xchg: TrimsockExchange): - signal_webrtc_message.emit(ACTION.Offer, _cmd) + signal_webrtc_message.emit(WEBRTC_ACTION.Offer, _cmd) ).on("webrtc/get/answer", func(_cmd, xchg: TrimsockExchange): - signal_webrtc_message.emit(ACTION.Answer, _cmd) + signal_webrtc_message.emit(WEBRTC_ACTION.Answer, _cmd) ).on("webrtc/get/candidate", func(_cmd, xchg: TrimsockExchange): - signal_webrtc_message.emit(ACTION.Candidate, _cmd) + signal_webrtc_message.emit(WEBRTC_ACTION.Candidate, _cmd) ).on_unknown(func(cmd, xchg: TrimsockExchange): _log("[srv] Unknown command: %s" % cmd) return TrimsockCommand.error_from(cmd, "error", ["Unknown command", cmd.name]) @@ -266,7 +266,7 @@ func _session_id(length: int = 4) -> String: ## [br][br] ## Only the lobby's owner can start the lobby. func start_lobby(lobby_id: String) -> NohubResult.LobbyMessage: - var request := TrimsockCommand.request("signal/start/lobby") \ + var request := TrimsockCommand.request("webrtc/lobby/start") \ .with_params([lobby_id]) var xchg := _reactor.submit_request(request) @@ -300,22 +300,22 @@ func get_session() -> String: else: return "" -enum ACTION { +enum WEBRTC_ACTION { Offer, Answer, Candidate } -func send_webrtc_message(type: ACTION, id: String, data: Dictionary = {}) -> NohubResult: +func send_webrtc_message(type: WEBRTC_ACTION, id: String, data: Dictionary = {}) -> NohubResult: var request match type: - ACTION.Offer: - request = TrimsockCommand.request("signal/offer").with_params([id]) - ACTION.Answer: - request = TrimsockCommand.request("signal/answer").with_params([id]) - ACTION.Candidate: - request = TrimsockCommand.request("signal/candidate").with_params([id]) + WEBRTC_ACTION.Offer: + request = TrimsockCommand.request("webrtc/offer").with_params([id]) + WEBRTC_ACTION.Answer: + request = TrimsockCommand.request("webrtc/answer").with_params([id]) + WEBRTC_ACTION.Candidate: + request = TrimsockCommand.request("webrtc/candidate").with_params([id]) request.with_kv_map(data) @@ -327,4 +327,4 @@ func send_webrtc_message(type: ACTION, id: String, data: Dictionary = {}) -> Noh else: return _command_to_error(response) -#endregion \ No newline at end of file +#endregion diff --git a/nohub.gd/examples/webrtc/README.md b/nohub.gd/examples/webrtc/README.md new file mode 100644 index 0000000..b9b78e2 --- /dev/null +++ b/nohub.gd/examples/webrtc/README.md @@ -0,0 +1,4 @@ +# Nohub WebRTC Example + +- Download the latest binary from the releases in the official webrtc-native repo: https://github.com/godotengine/webrtc-native +- Unzip into the root of the Godot project diff --git a/nohub.gd/examples/webrtc/browser_webrtc.tscn b/nohub.gd/examples/webrtc/browser_webrtc.tscn new file mode 100644 index 0000000..c83f120 --- /dev/null +++ b/nohub.gd/examples/webrtc/browser_webrtc.tscn @@ -0,0 +1,703 @@ +[gd_scene load_steps=10 format=3 uid="uid://4n0eqw25becg"] + +[ext_resource type="Texture2D" uid="uid://j2fymrfv4c3r" path="res://resources/icons/lock.svg" id="1_47dd4"] +[ext_resource type="Texture2D" uid="uid://clqecwtwyaubp" path="res://resources/icons/join.svg" id="1_avfqc"] +[ext_resource type="Texture2D" uid="uid://6a7vvotwb3rt" path="res://resources/icons/delete.svg" id="1_c5nld"] +[ext_resource type="Texture2D" uid="uid://bdrspgruqn4ft" path="res://resources/icons/hide.svg" id="1_s7umg"] + +[sub_resource type="GDScript" id="GDScript_xkt7l"] +script/source = "extends Control + +@onready var host_input: LineEdit = %\"Host Input\" +@onready var connect_button: Button = %\"Connect Button\" +@onready var disconnect_button: Button = %\"Disconnect Button\" +@onready var status_label: Label = %\"Status Label\" + +@onready var game_id_input: LineEdit = %\"Game ID Input\" +@onready var set_game_id_button: Button = %\"Set Game ID Button\" + +@onready var lobby_list: ItemList = %\"Lobby List\" + +@onready var create_name_input: LineEdit = %\"Lobby Name Input\" +@onready var create_button: Button = %\"Create Lobby Button\" +@onready var address_label: Label = %\"Address Label\" +@onready var selected_lobby_label: Label = %\"Selected Lobby Label\" + +@onready var lock_lobby_button: Button = %\"Lock Lobby Button\" +@onready var hide_lobby_button: Button = %\"Hide Lobby Button\" +@onready var join_lobby_button: Button = %\"Join Lobby Button\" +@onready var delete_lobby_button: Button = %\"Delete Lobby Button\" +@onready var leave_lobby_button: Button = %\"Leave Lobby Button\" +@onready var start_lobby_button: Button = %\"Start Lobby Button\" + +@onready var modal: Popup = %Modal +@onready var modal_message_label: Label = %\"Message Label\" +@onready var modal_confirm_button: Button = %\"Confirm Button\" + +var lock_icon := preload(\"res://resources/icons/lock.svg\") as Texture2D +var unlock_icon := preload(\"res://resources/icons/unlock.svg\") as Texture2D +var hide_icon := preload(\"res://resources/icons/hide.svg\") as Texture2D +var publish_icon := preload(\"res://resources/icons/publish.svg\") as Texture2D + +var list_interval: float = 2.0 +var time: float = 0.0 + + +var _connection: StreamPeerTCP = null +var _client: NohubClient = null +var _last_list: float = -1. +var _lobbies_in_list: Array[NohubLobby] = [] + +var _selected_lobby: NohubLobby = null +var _lobby_list_columns = [\"ID\", \"Name\", \"Locked?\", \"Hidden?\", \"Data\"] + +func set_selected_lobby(lobby: NohubLobby) -> void: + _selected_lobby = lobby + if is_instance_valid(lobby): + selected_lobby_label.text = lobby.id + lock_lobby_button.text = \"Unlock\" if lobby.is_locked else \"Lock\" + lock_lobby_button.icon = unlock_icon if lobby.is_locked else lock_icon + + hide_lobby_button.text = \"Hide\" if lobby.is_visible else \"Publish\" + hide_lobby_button.icon = hide_icon if lobby.is_visible else publish_icon + else: + selected_lobby_label.text = \"\" + lock_lobby_button.text = \"Lock\" + lock_lobby_button.icon = lock_icon + hide_lobby_button.text = \"Hide\" + hide_lobby_button.icon = hide_icon + +func get_selected_lobby() -> NohubLobby: + return _selected_lobby + +func popup(text: String) -> void: + modal_message_label.text = text + modal.show() + +func _ready(): + connect_button.pressed.connect(_connect) + disconnect_button.pressed.connect(_disconnect) + create_button.pressed.connect(_create_lobby) + set_game_id_button.pressed.connect(_set_game_id) + lobby_list.set_select_mode(ItemList.SELECT_MULTI) + lobby_list.multi_selected.connect(_select_lobby) + lobby_list.max_columns = _lobby_list_columns.size() + + join_lobby_button.pressed.connect(_join_lobby) + lock_lobby_button.pressed.connect(_lock_lobby) + hide_lobby_button.pressed.connect(_hide_lobby) + delete_lobby_button.pressed.connect(_delete_lobby) + leave_lobby_button.pressed.connect(_leave_lobby) + start_lobby_button.pressed.connect(_start_lobby) + + modal_confirm_button.pressed.connect(func(): + modal.hide() + ) + +func _process(dt: float) -> void: + time += dt + status_label.text = \"None\" + + if _connection != null: + match _connection.get_status(): + StreamPeerTCP.STATUS_NONE: status_label.text = \"None\" + StreamPeerTCP.STATUS_CONNECTING: status_label.text = \"Connecting...\" + StreamPeerTCP.STATUS_CONNECTED: status_label.text = \"Connected\" + StreamPeerTCP.STATUS_ERROR: status_label.text = \"Error\" + + if _connection.get_status() == StreamPeerTCP.STATUS_CONNECTED and address_label.text == \"\": + # Get IP + address_label.text = \"...\" + var ip := await _get_ip() + address_label.text = ip + + _connection.poll() + + if _client != null: + _client.poll() + + if time >= _last_list + list_interval: + _update_list() + _last_list = time + + if ws_peer_id == null: + _get_session_id() + +func _connect() -> void: + var input := host_input.text + var host := input + var port := 9980 + + if input.contains(\":\"): + var at := input.rfind(\":\") + host = input.substr(0, at) + port = int(input.substr(at + 1)) + + print(\"Connecting to nohub at %s:%d\" % [host, port]) + + _connection = StreamPeerTCP.new() + var err := _connection.connect_to_host(host, port) + if err != OK: + popup(\"Couldn't connect to nohub at %s:%d - %s\" % [host, port, error_string(err)]) + return + + _client = NohubClient.new(_connection) + +func _disconnect() -> void: + if _connection == null: + return + + print(\"Disconnecting from host\") + _connection.disconnect_from_host() + _connection = null + _client = null + + lobby_list.clear() + _lobbies_in_list.clear() + address_label.text = \"\" + +func _create_lobby() -> void: + if not _client: return + + var name_text := create_name_input.text + var lobby_response := await _client.create_lobby(\"enet://\" + address_label.text, { \"name\": name_text }) + if not lobby_response.is_success(): + popup(\"Failed to create lobby!\\n%s\" % [lobby_response]) + return + + var lobby := lobby_response.value() + print(\"Created lobby: %s\" % lobby) + + # We could have submitted data at create, this is to excercise the `set_data()` call + var data := lobby.data.duplicate() + data.merge({ \"created-at\": Time.get_datetime_string_from_system() }) + var set_response := await _client.set_lobby_data(lobby.id, data) + if not set_response.is_success(): + print(\"Failed to set lobby data: %s\" % [set_response]) + + create_name_input.text = \"\" + _update_list() + +func _set_game_id() -> void: + if not _client: return + + var game_id := game_id_input.text + var response := await _client.set_game(game_id) + + if not response.is_success(): + popup(\"Failed to set game ID to %s!\\n%s\" % [game_id, response]) + else: + print(\"Game ID set to %s\" % [game_id]) + + _update_list() + +func _select_lobby(clicked_idx: int, _selected: bool = true) -> void: + @warning_ignore(\"integer_division\") + var row_idx := clicked_idx / lobby_list.max_columns - 1 + if row_idx < 0: + # User clicked header row, do nothing + return + + var lobby := _lobbies_in_list[row_idx] + set_selected_lobby(lobby) + + # highlight the whole row using multiselect + for i in lobby_list.max_columns: + lobby_list.select(((row_idx + 1) * lobby_list.max_columns) + i, false) + +func _join_lobby() -> void: + if not _client: return + + var lobby := get_selected_lobby() + if not is_instance_valid(lobby): + # No lobby selected! + return + + var response := await _client.join_lobby(lobby.id) + if not response.is_success(): + popup(\"Failed to join lobby #%s!\\n%s\" % [lobby.id, response]) + return + + var address := response.value() + popup(\"Lobby address: %s\" % [address]) + +func _lock_lobby(): + if not _client: return + + var lobby := get_selected_lobby() + if not is_instance_valid(lobby): + # No lobby selected! + return + + var response: NohubResult + if lobby.is_locked: + response = await _client.unlock_lobby(lobby.id) + else: + response = await _client.lock_lobby(lobby.id) + + if not response.is_success(): + popup(\"Failed to %s lobby!\\n%s\" % [\"unlock\" if lobby.is_locked else \"lock\", response]) + else: + _update_list() + +func _hide_lobby(): + if not _client: return + + var lobby := get_selected_lobby() + if not is_instance_valid(lobby): + # No lobby selected! + return + + var response: NohubResult + if lobby.is_visible: + response = await _client.hide_lobby(lobby.id) + else: + response = await _client.publish_lobby(lobby.id) + + if not response.is_success(): + popup(\"Failed to %s lobby!\\n%s\" % [\"hide\" if lobby.is_visible else \"publish\", response]) + else: + _update_list() + +func _delete_lobby(): + if not _client: return + + var lobby := get_selected_lobby() + if not is_instance_valid(lobby): + # No lobby selected! + return + + var response := await _client.delete_lobby(lobby.id) + if not response.is_success(): + popup(\"Failed to delete lobby #%s!\\n%s\" % [lobby.id, response]) + else: + _update_list() + +func _update_list() -> void: + if _client == null: return + + var response := await _client.list_lobbies() + if not response.is_success(): + push_warning(\"Failed to update lobby list! %s\" % [response]) + return + + var lobbies := response.value() + print(\"Received lobbies: %s\" % [lobbies]) + + lobby_list.clear() + + # Add header + if lobby_list.item_count == 0: + for lobby_header in _lobby_list_columns: + lobby_list.add_item(lobby_header, null, false) + + # Add lobbies + var prev_selected_lobby = get_selected_lobby() + set_selected_lobby(null) + for lobby in lobbies: + lobby_list.add_item(lobby.id) + lobby_list.add_item(lobby.data.get(\"name\", \"\")) + lobby_list.add_item(\"Locked\" if lobby.is_locked else \"Open\", lock_icon if lobby.is_locked else unlock_icon) + lobby_list.add_item(\"Hidden\" if not lobby.is_visible else \"Public\", hide_icon if not lobby.is_visible else publish_icon) + lobby_list.add_item(JSON.stringify(lobby.data)) + _update_list_with_selected_lobby(lobby, prev_selected_lobby, lobby_list.item_count - 1) + + _lobbies_in_list.assign(lobbies) + +func _update_list_with_selected_lobby(lobby: NohubLobby, prev_selected: NohubLobby, lobby_list_index: int) -> void: + if prev_selected != null: + var search_id := prev_selected.id + if lobby.id == search_id: + set_selected_lobby(lobby) + _select_lobby(lobby_list_index) + +func _get_ip() -> String: + if not _client: return _get_ip_local() + + var ip := await _client.whereami() + if not ip: ip = _get_ip_local() + + return ip + +func _get_ip_local() -> String: + return IP.get_local_addresses()[0] + +#region WebRTCMultiplayerPeer + +var web_rtc_peer: WebRTCMultiplayerPeer +var ws_peer_id = null +var turn_server_enabled = false + +# Patterned [stun:URI, turn:URI], for default to free unlimited STUN +var STUN_TURN_SERVER_URLS = ['stun:stun.cloudflare.com'] +# This will be overwritten by a SetIceServer event from server if turn is set up in Cloudflare +var ICE_SERVERS = null + +func _get_session_id() -> void: + if not _client: return + + var session_id := await _client.get_session() + if not session_id: + push_error('Error: No session') + + ws_peer_id = session_id + _client.signal_webrtc_create_new_peer_connection.connect(_network_create_new_peer_connection) + _client.signal_webrtc_message.connect(_network_process_signal) + start_web_rtc(session_id) + +func start_web_rtc(id: String): + web_rtc_peer = WebRTCMultiplayerPeer.new() + web_rtc_peer.create_mesh(int(id)) + multiplayer.multiplayer_peer = web_rtc_peer + prints('HECK:', id) + + multiplayer.peer_connected.connect(func(id): print(\"Add player: \", id)) + multiplayer.peer_disconnected.connect(func(id): print(\"Remove player: \", id)) + +func _start_lobby(): + if not _client: return + + var lobby := get_selected_lobby() + if not is_instance_valid(lobby): + # No lobby selected! + return + + var response := await _client.start_lobby(lobby.id) + if not response.is_success(): + popup(\"Failed to start lobby #%s!\\n%s\" % [lobby.id, response]) + else: + var peer_list := response.value() + # CRITICAL: Blocking: this only triggers for the session owner, so they get the list... + popup('Staring with players: ' + peer_list) + await get_tree().create_timer(1.0).timeout + %Modal.hide() + _update_list() + +func _leave_lobby() -> void: + if not _client: return + + var lobby := get_selected_lobby() + if not is_instance_valid(lobby): + # No lobby selected! + return + + var response := await _client.leave_lobby(lobby.id) + if not response.is_success(): + popup(\"Failed to leave lobby #%s!\\n%s\" % [lobby.id, response]) + return + + var address := response.value() + popup(\"Lobby address: %s\" % [address]) + +func _network_create_new_peer_connection(id: int): + if id != int(ws_peer_id): + var new_peer_connection: WebRTCPeerConnection = WebRTCPeerConnection.new() + + # If the SetIceServers event didn't occur, we might need to use the default + if ICE_SERVERS == null: + ICE_SERVERS = {\"iceServers\": [ {\"urls\": STUN_TURN_SERVER_URLS}]} + + new_peer_connection.initialize(ICE_SERVERS) + print(\"binding id \" + str(id) + \" my id is \" + str(ws_peer_id)) + + new_peer_connection.session_description_created.connect(self._offerCreated.bind(id)) + new_peer_connection.ice_candidate_created.connect(self._iceCandidateCreated.bind(id)) + web_rtc_peer.add_peer(new_peer_connection, id) + if id < web_rtc_peer.get_unique_id(): + new_peer_connection.create_offer() + +func _network_process_signal(type: NohubClient.WEBRTC_ACTION, message: TrimsockCommand): + match type: + NohubClient.WEBRTC_ACTION.Offer: + web_rtc_peer.get_peer(int(message.kv_map.orgPeer)).connection.set_remote_description(\"offer\", message.kv_map.data) + NohubClient.WEBRTC_ACTION.Answer: + web_rtc_peer.get_peer(int(message.kv_map.orgPeer)).connection.set_remote_description(\"answer\", message.kv_map.data) + NohubClient.WEBRTC_ACTION.Candidate: + web_rtc_peer.get_peer(int(message.kv_map.orgPeer)).connection.add_ice_candidate(str(message.kv_map.mid), int(message.kv_map.index), message.kv_map.sdp) + +func _offerCreated(type, data, id: int): + if !web_rtc_peer.has_peer(id): + return + + web_rtc_peer.get_peer(id).connection.set_local_description(type, data) + + if type == \"offer\": + _sendOffer(id, data) + else: + _sendAnswer(id, data) + +func _sendOffer(id: int, data): + var message: Dictionary = { + \"peer\": str(id), + \"orgPeer\": ws_peer_id, + \"data\": data, + } + + _client.send_webrtc_message(_client.ACTION.Offer, str(id), message) + +func _sendAnswer(id: int, data): + var message = { + \"peer\": str(id), + \"orgPeer\": ws_peer_id, + \"data\": data, + } + _client.send_webrtc_message(_client.ACTION.Answer, str(id), message) + +func _iceCandidateCreated(midName, indexName, sdpName, id: int): + var message = { + \"peer\": str(id), + \"orgPeer\": ws_peer_id, + \"mid\": midName, + \"index\": str(indexName), + \"sdp\": sdpName, + } + _client.send_webrtc_message(_client.ACTION.Candidate, str(id), message) + +#endregion +" + +[sub_resource type="Gradient" id="Gradient_xtbls"] +offsets = PackedFloat32Array(0.47234, 0.475177) +colors = PackedColorArray(0.87451, 0.513726, 0, 1, 0.854902, 0.470588, 0, 0) + +[sub_resource type="GradientTexture2D" id="GradientTexture2D_831n5"] +gradient = SubResource("Gradient_xtbls") +fill = 1 +fill_from = Vector2(0.5, 0.5) + +[sub_resource type="Gradient" id="Gradient_ney1d"] +offsets = PackedFloat32Array(0.469504, 0.470922) +colors = PackedColorArray(0, 0.52549, 0, 1, 0, 0.482353, 0, 0) + +[sub_resource type="GradientTexture2D" id="GradientTexture2D_d1rvb"] +gradient = SubResource("Gradient_ney1d") +fill = 1 +fill_from = Vector2(0.5, 0.5) + +[node name="BrowserWebRTC" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = SubResource("GDScript_xkt7l") + +[node name="Browser UI" type="HBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Left Panel" type="PanelContainer" parent="Browser UI"] +layout_mode = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="Browser UI/Left Panel"] +layout_mode = 2 + +[node name="Host Input" type="LineEdit" parent="Browser UI/Left Panel/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +text = "localhost:9980" +placeholder_text = "Address" + +[node name="Buttons Container" type="HBoxContainer" parent="Browser UI/Left Panel/VBoxContainer"] +layout_mode = 2 + +[node name="Connect Button" type="Button" parent="Browser UI/Left Panel/VBoxContainer/Buttons Container"] +unique_name_in_owner = true +layout_mode = 2 +text = "Connect" + +[node name="Disconnect Button" type="Button" parent="Browser UI/Left Panel/VBoxContainer/Buttons Container"] +unique_name_in_owner = true +layout_mode = 2 +text = "Disconnect" + +[node name="Status Container" type="HBoxContainer" parent="Browser UI/Left Panel/VBoxContainer"] +layout_mode = 2 + +[node name="Status Prefix" type="Label" parent="Browser UI/Left Panel/VBoxContainer/Status Container"] +layout_mode = 2 +text = "Status:" + +[node name="Status Label" type="Label" parent="Browser UI/Left Panel/VBoxContainer/Status Container"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +text = "None" + +[node name="HSeparator" type="HSeparator" parent="Browser UI/Left Panel/VBoxContainer"] +layout_mode = 2 + +[node name="Game ID Input" type="LineEdit" parent="Browser UI/Left Panel/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +placeholder_text = "Game ID" + +[node name="Set Game ID Button" type="Button" parent="Browser UI/Left Panel/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +text = "Set Game ID" + +[node name="HSeparator2" type="HSeparator" parent="Browser UI/Left Panel/VBoxContainer"] +layout_mode = 2 + +[node name="Address Prefix" type="Label" parent="Browser UI/Left Panel/VBoxContainer"] +layout_mode = 2 +text = "Your address:" + +[node name="Address Label" type="Label" parent="Browser UI/Left Panel/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="Browser UI"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="List Panel" type="ScrollContainer" parent="Browser UI/VBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="Lobby List" type="ItemList" parent="Browser UI/VBoxContainer/List Panel"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +max_text_lines = 4 +auto_height = true +same_column_width = true +fixed_column_width = 160 +fixed_icon_size = Vector2i(20, 20) + +[node name="Tools" type="HBoxContainer" parent="Browser UI/VBoxContainer"] +layout_mode = 2 + +[node name="Create Container" type="PanelContainer" parent="Browser UI/VBoxContainer/Tools"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="VBoxContainer" type="VBoxContainer" parent="Browser UI/VBoxContainer/Tools/Create Container"] +layout_mode = 2 + +[node name="Lobby Name Label" type="Label" parent="Browser UI/VBoxContainer/Tools/Create Container/VBoxContainer"] +layout_mode = 2 +text = "Lobby name: " + +[node name="Lobby Name Input" type="LineEdit" parent="Browser UI/VBoxContainer/Tools/Create Container/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="Create Lobby Button" type="Button" parent="Browser UI/VBoxContainer/Tools/Create Container/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 4 +text = "Create Lobby" + +[node name="Tools Container" type="PanelContainer" parent="Browser UI/VBoxContainer/Tools"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="Rows" type="VBoxContainer" parent="Browser UI/VBoxContainer/Tools/Tools Container"] +layout_mode = 2 + +[node name="Selected Lobby Row" type="HBoxContainer" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows"] +layout_mode = 2 + +[node name="Selected Lobby Prefix" type="Label" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows/Selected Lobby Row"] +layout_mode = 2 +text = "Selected Lobby: " + +[node name="Selected Lobby Label" type="Label" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows/Selected Lobby Row"] +unique_name_in_owner = true +layout_mode = 2 + +[node name="Tools Row" type="HBoxContainer" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows"] +layout_mode = 2 +size_flags_vertical = 10 + +[node name="Lock Lobby Button" type="Button" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows/Tools Row"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +text = "Lock" +icon = ExtResource("1_47dd4") +expand_icon = true + +[node name="Hide Lobby Button" type="Button" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows/Tools Row"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +text = "Hide" +icon = ExtResource("1_s7umg") +expand_icon = true + +[node name="Join Lobby Button" type="Button" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows/Tools Row"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +text = "Join" +icon = ExtResource("1_avfqc") +expand_icon = true + +[node name="Delete Lobby Button" type="Button" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows/Tools Row"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +text = "Delete" +icon = ExtResource("1_c5nld") +expand_icon = true + +[node name="Leave Lobby Button" type="Button" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows/Tools Row"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +text = "Leave" +icon = SubResource("GradientTexture2D_831n5") +expand_icon = true + +[node name="Start Lobby Button" type="Button" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows/Tools Row"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +text = "Start" +icon = SubResource("GradientTexture2D_d1rvb") +expand_icon = true + +[node name="Modal" type="PopupPanel" parent="."] +unique_name_in_owner = true +initial_position = 4 +size = Vector2i(248, 168) + +[node name="PanelContainer" type="PanelContainer" parent="Modal"] +custom_minimum_size = Vector2(240, 160) +offset_left = 4.0 +offset_top = 4.0 +offset_right = 244.0 +offset_bottom = 164.0 + +[node name="MarginContainer" type="MarginContainer" parent="Modal/PanelContainer"] +layout_mode = 2 +theme_override_constants/margin_left = 4 +theme_override_constants/margin_top = 4 +theme_override_constants/margin_right = 4 +theme_override_constants/margin_bottom = 4 + +[node name="VBoxContainer" type="VBoxContainer" parent="Modal/PanelContainer/MarginContainer"] +layout_mode = 2 + +[node name="Message Label" type="Label" parent="Modal/PanelContainer/MarginContainer/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +text = "Message here" + +[node name="Confirm Button" type="Button" parent="Modal/PanelContainer/MarginContainer/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 4 +text = "OK" diff --git a/nohub/src/signaling/signaling.module.ts b/nohub/src/signaling/signaling.module.ts index 47bfad9..03d10b1 100644 --- a/nohub/src/signaling/signaling.module.ts +++ b/nohub/src/signaling/signaling.module.ts @@ -23,22 +23,7 @@ export class SignalingModule implements Module { configure(reactor: NohubReactor) { reactor - .on("signaling/greet/lobby", (cmd, xchg) => { - const lobbyId = requireSingleParam(cmd, "Missing lobby id!"); - - const session = sessionOf(xchg); - const lobby = this.lobbyRepository.requireInGame( - lobbyId, - session.gameId, - ); - - this.broadcastService.broadcast(lobby, { - name: "signaling/greet", - text: "Hi!", - }); - }) - - .on("signal/start/lobby", (cmd, xchg) => { + .on("webrtc/lobby/start", (cmd, xchg) => { const lobbyId = requireSingleParam(cmd, "Missing lobby id!"); const session = sessionOf(xchg); @@ -54,7 +39,7 @@ export class SignalingModule implements Module { // Sends to all lobby participants, they will begin WebRTC connections with each peer by sessionId. this.broadcastService.broadcast(lobby, { - name: "signal/start", + name: "webrtc/start", kvParams: [...payload.entries()], }); @@ -64,26 +49,27 @@ export class SignalingModule implements Module { // TODO: Are we able to listen for the results each one of the broadcasts? xchg.reply({ text: lobby.participants.join(",") }); }) - .on("signal/offer", (cmd, xchg) => { + .on("webrtc/offer", (cmd, xchg) => { const sessionId = requireSingleParam(cmd, "Missing session id!"); this.broadcastService.unicast(sessionId, { - name: "signal/get/offer", + name: "webrtc/get/offer", kvMap: cmd.kvMap, }); - // TODO: Sending an offer and getting one back could be something we could use the xchg for - // TODO: Handle errors at each step, for example: If STUN is not enough, indicate we need TURN. + // TODO: Sending an offer and getting one back could be something we could use the xchg for? + // TODO: Reply? Answer, Offer, Candidate are each "handshake" steps. + // TODO: Handle errors, for example: if STUN is not enough, indicate we need TURN. // xchg.reply }) - .on("signal/answer", (cmd, xchg) => { + .on("webrtc/answer", (cmd, xchg) => { const sessionId = requireSingleParam(cmd, "Missing session id!"); this.broadcastService.unicast(sessionId, { - name: "signal/get/answer", + name: "webrtc/get/answer", kvMap: cmd.kvMap, }); }) - .on("signal/candidate", (cmd, xchg) => { + .on("webrtc/candidate", (cmd, xchg) => { const sessionId = requireSingleParam(cmd, "Missing session id!"); this.logger.info( { sessionId }, @@ -91,7 +77,7 @@ export class SignalingModule implements Module { sessionId, ); this.broadcastService.unicast(sessionId, { - name: "signal/get/candidate", + name: "webrtc/get/candidate", kvMap: cmd.kvMap, }); }); From ce788aa0fce073b68b307dfe61750770fcc29699 Mon Sep 17 00:00:00 2001 From: Andrew Davis Date: Thu, 25 Dec 2025 23:06:02 -0500 Subject: [PATCH 08/20] chore: fix lint issues using bun --fix --- nohub/src/broadcast/broadcast.service.ts | 6 +----- nohub/src/sessions/session.api.ts | 4 ++-- nohub/src/signaling/signaling.module.ts | 8 ++++---- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/nohub/src/broadcast/broadcast.service.ts b/nohub/src/broadcast/broadcast.service.ts index 6477f37..8567a8a 100644 --- a/nohub/src/broadcast/broadcast.service.ts +++ b/nohub/src/broadcast/broadcast.service.ts @@ -2,11 +2,7 @@ import type { CommandSpec, Exchange } from "@foxssake/trimsock-js"; import { DataNotFoundError } from "@src/errors"; import type { Lobby } from "@src/lobbies/lobby"; import type { NohubReactor } from "@src/nohub"; -import type { - SessionData, - SessionId, - SessionSocket, -} from "@src/sessions/session"; +import type { SessionId, SessionSocket } from "@src/sessions/session"; import type { SessionRepository } from "@src/sessions/session.repository"; export class BroadcastService { diff --git a/nohub/src/sessions/session.api.ts b/nohub/src/sessions/session.api.ts index 1a6298b..092bc00 100644 --- a/nohub/src/sessions/session.api.ts +++ b/nohub/src/sessions/session.api.ts @@ -1,3 +1,5 @@ +// import { nanoid } from "nanoid"; +import * as crypto from "node:crypto"; import type { Exchange } from "@foxssake/trimsock-js"; import type { SessionsConfig } from "@src/config"; import { LimitError, LockedError } from "@src/errors"; @@ -7,8 +9,6 @@ import type { LobbyLookup } from "@src/lobbies/lobby.repository"; import { rootLogger } from "@src/logger"; import { emptyMetrics, type MetricsHolder } from "@src/metrics/metrics"; import type { Socket } from "bun"; -// import { nanoid } from "nanoid"; -import * as crypto from "crypto"; import type { SessionData } from "./session"; import type { SessionRepository } from "./session.repository"; diff --git a/nohub/src/signaling/signaling.module.ts b/nohub/src/signaling/signaling.module.ts index 03d10b1..8774822 100644 --- a/nohub/src/signaling/signaling.module.ts +++ b/nohub/src/signaling/signaling.module.ts @@ -1,8 +1,8 @@ -import { rootLogger } from "@src/logger"; import type { BroadcastModule } from "@src/broadcast/broadcast.module"; import type { BroadcastService } from "@src/broadcast/broadcast.service"; import type { LobbyModule } from "@src/lobbies/lobby.module"; import type { LobbyRepository } from "@src/lobbies/lobby.repository"; +import { rootLogger } from "@src/logger"; import type { Module } from "@src/module"; import type { NohubReactor } from "@src/nohub"; import { sessionOf } from "@src/sessions/session.api"; @@ -49,7 +49,7 @@ export class SignalingModule implements Module { // TODO: Are we able to listen for the results each one of the broadcasts? xchg.reply({ text: lobby.participants.join(",") }); }) - .on("webrtc/offer", (cmd, xchg) => { + .on("webrtc/offer", (cmd, _xchg) => { const sessionId = requireSingleParam(cmd, "Missing session id!"); this.broadcastService.unicast(sessionId, { @@ -62,14 +62,14 @@ export class SignalingModule implements Module { // TODO: Handle errors, for example: if STUN is not enough, indicate we need TURN. // xchg.reply }) - .on("webrtc/answer", (cmd, xchg) => { + .on("webrtc/answer", (cmd, _xchg) => { const sessionId = requireSingleParam(cmd, "Missing session id!"); this.broadcastService.unicast(sessionId, { name: "webrtc/get/answer", kvMap: cmd.kvMap, }); }) - .on("webrtc/candidate", (cmd, xchg) => { + .on("webrtc/candidate", (cmd, _xchg) => { const sessionId = requireSingleParam(cmd, "Missing session id!"); this.logger.info( { sessionId }, From 94d176eb3de4b260e406e74c70b3184dbb2799c1 Mon Sep 17 00:00:00 2001 From: Andrew Davis Date: Thu, 25 Dec 2025 23:25:01 -0500 Subject: [PATCH 09/20] fix: use correct enum values and add webrtc folder to .gitignore --- nohub.gd/.gitignore | 1 + nohub.gd/examples/webrtc/README.md | 2 +- nohub.gd/examples/webrtc/browser_webrtc.tscn | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/nohub.gd/.gitignore b/nohub.gd/.gitignore index 288bf50..b2e7e94 100644 --- a/nohub.gd/.gitignore +++ b/nohub.gd/.gitignore @@ -2,3 +2,4 @@ .godot/ build/ +webrtc/ \ No newline at end of file diff --git a/nohub.gd/examples/webrtc/README.md b/nohub.gd/examples/webrtc/README.md index b9b78e2..8e9da8d 100644 --- a/nohub.gd/examples/webrtc/README.md +++ b/nohub.gd/examples/webrtc/README.md @@ -1,4 +1,4 @@ # Nohub WebRTC Example - Download the latest binary from the releases in the official webrtc-native repo: https://github.com/godotengine/webrtc-native -- Unzip into the root of the Godot project +- Unzip into the root of a Godot project (in this case, the `/nohub.gd`, where the .godot folder is located) diff --git a/nohub.gd/examples/webrtc/browser_webrtc.tscn b/nohub.gd/examples/webrtc/browser_webrtc.tscn index c83f120..c384d04 100644 --- a/nohub.gd/examples/webrtc/browser_webrtc.tscn +++ b/nohub.gd/examples/webrtc/browser_webrtc.tscn @@ -433,7 +433,7 @@ func _sendOffer(id: int, data): \"data\": data, } - _client.send_webrtc_message(_client.ACTION.Offer, str(id), message) + _client.send_webrtc_message(_client.WEBRTC_ACTION.Offer, str(id), message) func _sendAnswer(id: int, data): var message = { @@ -441,7 +441,7 @@ func _sendAnswer(id: int, data): \"orgPeer\": ws_peer_id, \"data\": data, } - _client.send_webrtc_message(_client.ACTION.Answer, str(id), message) + _client.send_webrtc_message(_client.WEBRTC_ACTION.Answer, str(id), message) func _iceCandidateCreated(midName, indexName, sdpName, id: int): var message = { @@ -451,7 +451,7 @@ func _iceCandidateCreated(midName, indexName, sdpName, id: int): \"index\": str(indexName), \"sdp\": sdpName, } - _client.send_webrtc_message(_client.ACTION.Candidate, str(id), message) + _client.send_webrtc_message(_client.WEBRTC_ACTION.Candidate, str(id), message) #endregion " From 49a03c67e4d8ee7a51ccd81578eee16eec91466e Mon Sep 17 00:00:00 2001 From: Andrew Davis Date: Sun, 18 Jan 2026 19:31:45 -0500 Subject: [PATCH 10/20] feat: update session to use number with nanoid customAlphabet --- nohub.gd/addons/nohub.gd/nohub.gd | 2 ++ nohub.gd/examples/webrtc/browser_webrtc.tscn | 6 +----- nohub/.env.example | 4 ++++ nohub/spec/fixtures.ts | 3 +++ nohub/spec/lobbies/lobby.service.spec.ts | 1 + nohub/src/config.ts | 1 + nohub/src/sessions/session.api.ts | 13 ++++++------- 7 files changed, 18 insertions(+), 12 deletions(-) diff --git a/nohub.gd/addons/nohub.gd/nohub.gd b/nohub.gd/addons/nohub.gd/nohub.gd index 5af87f6..4593ac7 100644 --- a/nohub.gd/addons/nohub.gd/nohub.gd +++ b/nohub.gd/addons/nohub.gd/nohub.gd @@ -1,2 +1,4 @@ @tool extends EditorPlugin + + diff --git a/nohub.gd/examples/webrtc/browser_webrtc.tscn b/nohub.gd/examples/webrtc/browser_webrtc.tscn index c384d04..c278d2f 100644 --- a/nohub.gd/examples/webrtc/browser_webrtc.tscn +++ b/nohub.gd/examples/webrtc/browser_webrtc.tscn @@ -349,7 +349,6 @@ func start_web_rtc(id: String): web_rtc_peer = WebRTCMultiplayerPeer.new() web_rtc_peer.create_mesh(int(id)) multiplayer.multiplayer_peer = web_rtc_peer - prints('HECK:', id) multiplayer.peer_connected.connect(func(id): print(\"Add player: \", id)) multiplayer.peer_disconnected.connect(func(id): print(\"Remove player: \", id)) @@ -416,9 +415,6 @@ func _network_process_signal(type: NohubClient.WEBRTC_ACTION, message: TrimsockC web_rtc_peer.get_peer(int(message.kv_map.orgPeer)).connection.add_ice_candidate(str(message.kv_map.mid), int(message.kv_map.index), message.kv_map.sdp) func _offerCreated(type, data, id: int): - if !web_rtc_peer.has_peer(id): - return - web_rtc_peer.get_peer(id).connection.set_local_description(type, data) if type == \"offer\": @@ -500,7 +496,7 @@ layout_mode = 2 [node name="Host Input" type="LineEdit" parent="Browser UI/Left Panel/VBoxContainer"] unique_name_in_owner = true layout_mode = 2 -text = "localhost:9980" +text = "0.0.0.0" placeholder_text = "Address" [node name="Buttons Container" type="HBoxContainer" parent="Browser UI/Left Panel/VBoxContainer"] diff --git a/nohub/.env.example b/nohub/.env.example index 641b122..17f2475 100644 --- a/nohub/.env.example +++ b/nohub/.env.example @@ -63,7 +63,11 @@ NOHUB_SESSIONS_MAX_COUNT=262144 # Maximum number of sessions from the same IP address ( disregarding port ) # Set to 0 to disable this limit NOHUB_SESSIONS_MAX_PER_ADDRESS=64 +# Set to true to create session ids as number +NOHUB_SESSIONS_ID_USE_NUMBER=false # Other ======================================================================= # Logging level - silent, trace, debug, info, warn, error, fatal NOHUB_LOG_LEVEL=info +# Websocket host for web deploy option +NOHUB_WEBSOCKET_HOST=0.0.0.0 \ No newline at end of file diff --git a/nohub/spec/fixtures.ts b/nohub/spec/fixtures.ts index 440b034..d9f1ab4 100644 --- a/nohub/spec/fixtures.ts +++ b/nohub/spec/fixtures.ts @@ -105,6 +105,7 @@ export const Lobbies = { ["player-count", "8"], ["player-capacity", "12"], ]), + participants: [], }), coolLobby: lobbyFixture({ @@ -119,6 +120,7 @@ export const Lobbies = { ["player-count", "9"], ["player-capacity", "16"], ]), + participants: [], }), mithrilParty: lobbyFixture({ @@ -133,6 +135,7 @@ export const Lobbies = { ["player-count", "4"], ["player-capacity", "6"], ]), + participants: [], }), all(): Lobby[] { diff --git a/nohub/spec/lobbies/lobby.service.spec.ts b/nohub/spec/lobbies/lobby.service.spec.ts index 1890edc..195812d 100644 --- a/nohub/spec/lobbies/lobby.service.spec.ts +++ b/nohub/spec/lobbies/lobby.service.spec.ts @@ -46,6 +46,7 @@ describe("LobbyService", () => { isVisible: true, isLocked: false, data: lobbyData, + participants: [Sessions.dave.id], }; const lobby = lobbyService.create( diff --git a/nohub/src/config.ts b/nohub/src/config.ts index 9d4ed2d..52f932f 100644 --- a/nohub/src/config.ts +++ b/nohub/src/config.ts @@ -45,6 +45,7 @@ export function readConfig(env: ConfigEnv) { defaultGameId: env.NOHUB_LOBBIES_DEFAULT_GAME_ID, maxCount: integer(env.NOHUB_SESSIONS_MAX_COUNT) ?? 262144, maxPerAddress: integer(env.NOHUB_SESSIONS_MAX_PER_ADDRESS) ?? 64, + idUseNumber: bool(env.NOHUB_SESSIONS_ID_USE_NUMBER) ?? false, }, }; } diff --git a/nohub/src/sessions/session.api.ts b/nohub/src/sessions/session.api.ts index 092bc00..afd5cba 100644 --- a/nohub/src/sessions/session.api.ts +++ b/nohub/src/sessions/session.api.ts @@ -1,5 +1,3 @@ -// import { nanoid } from "nanoid"; -import * as crypto from "node:crypto"; import type { Exchange } from "@foxssake/trimsock-js"; import type { SessionsConfig } from "@src/config"; import { LimitError, LockedError } from "@src/errors"; @@ -9,6 +7,7 @@ import type { LobbyLookup } from "@src/lobbies/lobby.repository"; import { rootLogger } from "@src/logger"; import { emptyMetrics, type MetricsHolder } from "@src/metrics/metrics"; import type { Socket } from "bun"; +import { customAlphabet, nanoid } from "nanoid"; import type { SessionData } from "./session"; import type { SessionRepository } from "./session.repository"; @@ -24,12 +23,12 @@ export class SessionApi { private metrics: MetricsHolder = emptyMetrics, ) {} - // generateSessionId(): string { - // return nanoid(this.config.idLength); - // } - generateSessionId(): string { - return Math.abs(new Int32Array(crypto.randomBytes(4).buffer)[0]).toString(); + if (this.config.idUseNumber) { + const nanoidNumber = customAlphabet("1234567890"); + return nanoidNumber(this.config.idLength); + } + return nanoid(this.config.idLength); } openSession(socket: Socket): void { From 07338bc014dc0157791643c2a6281c476191638e Mon Sep 17 00:00:00 2001 From: Andrew Davis Date: Sun, 18 Jan 2026 20:17:56 -0500 Subject: [PATCH 11/20] feat: remove extraneous code & call from example --- nohub.gd/addons/nohub.gd/nohub_client.gd | 30 +++----------------- nohub.gd/examples/webrtc/browser_webrtc.tscn | 19 ++++++------- 2 files changed, 12 insertions(+), 37 deletions(-) diff --git a/nohub.gd/addons/nohub.gd/nohub_client.gd b/nohub.gd/addons/nohub.gd/nohub_client.gd index 56d0121..7157648 100644 --- a/nohub.gd/addons/nohub.gd/nohub_client.gd +++ b/nohub.gd/addons/nohub.gd/nohub_client.gd @@ -30,14 +30,13 @@ class_name NohubClient var _connection: StreamPeerTCP var _reactor: TrimsockTCPClientReactor - ## Construct a client using the specified [param connection] func _init(connection: StreamPeerTCP): _connection = connection _connection.set_no_delay(true) _reactor = TrimsockTCPClientReactor.new(connection) - _setup_webrtc_reactor() + ## Poll the client ## [br][br] @@ -211,8 +210,9 @@ func _command_to_error(command: TrimsockCommand) -> NohubResult: #region WebRTC -# TODO: Determine the best place to define these signals. They are used in the browser +## Emitted during "webrtc/start" to kick off the peer connection signal signal_webrtc_create_new_peer_connection(id) +## Emitted to exchange offers and answers to finish establishing connections signal signal_webrtc_message(type, data) ## Setup the events for negotiating WebRTC connections using the signalling module @@ -221,7 +221,7 @@ signal signal_webrtc_message(type, data) ## these events will emit signals that the clients use to initialize WebRTC peer connections ## NOTE: This function is based on the _setup_reactor example in this file: ## https://github.com/foxssake/trimsock/blob/main/trimsock.gd/examples/server/server.gd -func _setup_webrtc_reactor() -> void: +func setup_webrtc_reactor() -> void: _reactor.on("webrtc/start", func(_cmd: TrimsockCommand, xchg: TrimsockExchange): var players = _cmd.kv_map['players'] as String for peer_id in players.split(',', false): @@ -237,31 +237,9 @@ func _setup_webrtc_reactor() -> void: return TrimsockCommand.error_from(cmd, "error", ["Unknown command", cmd.name]) ) - # TODO: Necessary? - _reactor.on_attach.connect(func(src: StreamPeerTCP): - var id := _session_id() - _log("[srv] New connection: " + id) - src.set_no_delay(true) - - _reactor.set_session(src, id) - _reactor.send(src, TrimsockCommand.simple("ohai")) - ) - - _reactor.on_detach.connect(func(src): - _log("[srv] Connection closed!") - ) - func _log(what: String) -> void: prints(what) -func _session_id(length: int = 4) -> String: - const charset := "abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "0123456789" - var id := "" - for i in length: - id += charset[randi() % charset.length()] - return id - - ## Start lobby, kicking off joining ## [br][br] ## Only the lobby's owner can start the lobby. diff --git a/nohub.gd/examples/webrtc/browser_webrtc.tscn b/nohub.gd/examples/webrtc/browser_webrtc.tscn index c278d2f..ebae446 100644 --- a/nohub.gd/examples/webrtc/browser_webrtc.tscn +++ b/nohub.gd/examples/webrtc/browser_webrtc.tscn @@ -74,7 +74,7 @@ func popup(text: String) -> void: modal_message_label.text = text modal.show() -func _ready(): +func _ready(): connect_button.pressed.connect(_connect) disconnect_button.pressed.connect(_disconnect) create_button.pressed.connect(_create_lobby) @@ -142,7 +142,8 @@ func _connect() -> void: return _client = NohubClient.new(_connection) - + _client.setup_webrtc_reactor() + func _disconnect() -> void: if _connection == null: return @@ -326,12 +327,12 @@ func _get_ip_local() -> String: var web_rtc_peer: WebRTCMultiplayerPeer var ws_peer_id = null -var turn_server_enabled = false -# Patterned [stun:URI, turn:URI], for default to free unlimited STUN -var STUN_TURN_SERVER_URLS = ['stun:stun.cloudflare.com'] -# This will be overwritten by a SetIceServer event from server if turn is set up in Cloudflare -var ICE_SERVERS = null +# Patterned [stun:URI, turn:URI]. Use cloudflare default for free unlimited STUN +var STUN_SERVER_URLS = ['stun:stun.cloudflare.com'] +# No TURN. Relay fallback not supported if empty and not included below +var TURN_SERVER_URLS = [] +var ICE_SERVERS = {\"iceServers\": [ {\"urls\": STUN_SERVER_URLS}]} func _get_session_id() -> void: if not _client: return @@ -392,10 +393,6 @@ func _network_create_new_peer_connection(id: int): if id != int(ws_peer_id): var new_peer_connection: WebRTCPeerConnection = WebRTCPeerConnection.new() - # If the SetIceServers event didn't occur, we might need to use the default - if ICE_SERVERS == null: - ICE_SERVERS = {\"iceServers\": [ {\"urls\": STUN_TURN_SERVER_URLS}]} - new_peer_connection.initialize(ICE_SERVERS) print(\"binding id \" + str(id) + \" my id is \" + str(ws_peer_id)) From fb40c01f6f8d7aa93615d0bbe8cdb9878e0f4ed3 Mon Sep 17 00:00:00 2001 From: Andrew Davis Date: Mon, 19 Jan 2026 10:49:38 -0500 Subject: [PATCH 12/20] chore: add spec api test for session, and new session id as number test --- nohub/spec/api/sessions.api.spec.ts | 17 +++++++++++++++++ nohub/spec/sessions/session.api.spec.ts | 14 ++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/nohub/spec/api/sessions.api.spec.ts b/nohub/spec/api/sessions.api.spec.ts index 3ac66db..7f2e047 100644 --- a/nohub/spec/api/sessions.api.spec.ts +++ b/nohub/spec/api/sessions.api.spec.ts @@ -23,4 +23,21 @@ describe("Sessions API", () => { expect(reply.text).not.toBeEmpty(); }); }); + + describe("getid", () => { + test("should respond", async () => { + const reply = await api + .client() + .send({ + name: "getid", + isRequest: true, + requestId: "", + }) + .onReply(); + + expect(reply.isSuccessResponse).toBeTrue(); + expect(reply.text).not.toBeEmpty(); + }); + }); + }); diff --git a/nohub/spec/sessions/session.api.spec.ts b/nohub/spec/sessions/session.api.spec.ts index 2899b5f..d616e1b 100644 --- a/nohub/spec/sessions/session.api.spec.ts +++ b/nohub/spec/sessions/session.api.spec.ts @@ -104,6 +104,20 @@ describe("SessionApi", () => { sessionApi.openSession(mockSocket(Sessions.dave.address)), ).not.toThrow(); }); + + + test("should create session with number", () => { + // Don't need fixtures + sessionRepository.clear(); + + // Set use number + config.idUseNumber = true; + + // Open 1 session and expect only numbers + sessionApi.openSession(mockSocket(Sessions.dave.address)); + expect([...sessionRepository.list()][0].id).toMatch(/^[0-9]+$/); + }); + }); describe("setGame", () => { From 2fe78590f95210929961a67d4913e56460743736 Mon Sep 17 00:00:00 2001 From: Andrew Davis Date: Mon, 19 Jan 2026 11:18:43 -0500 Subject: [PATCH 13/20] chore: add new tests for broadcast.service.ts --- .../spec/broadcast/broadcast.service.spec.ts | 101 ++++++++++++++++++ nohub/spec/sessions/session.api.spec.ts | 2 +- nohub/src/signaling/signaling.module.ts | 4 - 3 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 nohub/spec/broadcast/broadcast.service.spec.ts diff --git a/nohub/spec/broadcast/broadcast.service.spec.ts b/nohub/spec/broadcast/broadcast.service.spec.ts new file mode 100644 index 0000000..462114f --- /dev/null +++ b/nohub/spec/broadcast/broadcast.service.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, test, beforeEach, mock } from "bun:test"; +import { BroadcastService } from "@src/broadcast/broadcast.service"; +import { SessionRepository } from "@src/sessions/session.repository"; +import { SessionApi } from "@src/sessions/session.api"; +import { DataNotFoundError } from "@src/errors"; +import { Sessions, Lobbies, Games } from "@spec/fixtures"; +import type { NohubReactor } from "@src/nohub"; +import { LobbyRepository } from "@src/lobbies/lobby.repository"; +import { GameRepository } from "@src/games/game.repository"; +import { NohubEventBus } from "@src/events"; +import { readDefaultConfig } from "@src/config"; +import type { Socket } from "bun"; +import type { SessionData } from "@src/sessions/session"; +import { mockSocket } from "@spec/sessions/session.api.spec"; + +describe("BroadcastService", () => { + let service: BroadcastService; + let reactor: NohubReactor; + let sessionRepository: SessionRepository; + let sessionApi: SessionApi; + + let daveSocket: Socket; + let ericSocket: Socket; + + beforeEach(() => { + reactor = { + send: mock(() => ({}) as any), + } as unknown as NohubReactor; + + sessionRepository = new SessionRepository(); + const lobbyRepository = new LobbyRepository(); + const gameRepository = new GameRepository(); + Games.insert(gameRepository); + + const config = readDefaultConfig().sessions; + config.arbitraryGameId = true; // simplifying for this test + + sessionApi = new SessionApi( + sessionRepository, + lobbyRepository, + gameRepository, + new NohubEventBus(), + config + ); + + // create sessions using API + daveSocket = mockSocket(Sessions.dave.address); + ericSocket = mockSocket(Sessions.eric.address); + + sessionApi.openSession(daveSocket); + sessionApi.openSession(ericSocket); + + service = new BroadcastService(() => reactor, sessionRepository); + }); + + describe("unicast", () => { + test("should send command to session", () => { + const sessionId = daveSocket.data.id; + service.unicast(sessionId, { name: "command" }); + + expect(reactor.send).toHaveBeenCalled(); + // Verify it was called with the correct socket + expect(reactor.send).toHaveBeenCalledWith(daveSocket, { name: "command" }); + }); + + test("should throw if session not found", () => { + expect(() => service.unicast("unknown", { name: "command" })).toThrow(DataNotFoundError); + }); + }); + + describe("broadcast", () => { + test("should send to all participants", () => { + const daveId = daveSocket.data.id; + const ericId = ericSocket.data.id; + + // Create a lobby with participants using the generated IDs + const lobby = { ...Lobbies.davesLobby, participants: [daveId, ericId] }; + + service.broadcast(lobby, { name: "command" }); + + // Should broadcast to both + expect(reactor.send).toHaveBeenCalledTimes(2); + expect(reactor.send).toHaveBeenCalledWith(daveSocket, { name: "command" }); + expect(reactor.send).toHaveBeenCalledWith(ericSocket, { name: "command" }); + }); + + test("should skip missing sessions", () => { + const daveId = daveSocket.data.id; + + // One participant + const lobby = { ...Lobbies.davesLobby, participants: [daveId] }; + + service.broadcast(lobby, { name: "command" }); + + // Should broadcast to just 1 particpant + expect(reactor.send).toHaveBeenCalledTimes(1); + expect(reactor.send).toHaveBeenCalledWith(daveSocket, { name: "command" }); + + }); + }); +}); diff --git a/nohub/spec/sessions/session.api.spec.ts b/nohub/spec/sessions/session.api.spec.ts index d616e1b..6717a65 100644 --- a/nohub/spec/sessions/session.api.spec.ts +++ b/nohub/spec/sessions/session.api.spec.ts @@ -134,7 +134,7 @@ describe("SessionApi", () => { }); }); -function mockSocket(address: string): Socket { +export function mockSocket(address: string): Socket { return { remoteAddress: address, write: ( diff --git a/nohub/src/signaling/signaling.module.ts b/nohub/src/signaling/signaling.module.ts index 8774822..b830db4 100644 --- a/nohub/src/signaling/signaling.module.ts +++ b/nohub/src/signaling/signaling.module.ts @@ -8,9 +8,6 @@ import type { NohubReactor } from "@src/nohub"; import { sessionOf } from "@src/sessions/session.api"; import { requireSingleParam } from "@src/validators"; -// This is an example module to demonstrate how to inject the BroadcastService -// into custom modules and classes - export class SignalingModule implements Module { private readonly broadcastService: BroadcastService; private readonly lobbyRepository: LobbyRepository; @@ -46,7 +43,6 @@ export class SignalingModule implements Module { this.logger.info({ lobbyId }, "Starting lobby #%s", lobbyId); // Reply back to lobby host that we started. - // TODO: Are we able to listen for the results each one of the broadcasts? xchg.reply({ text: lobby.participants.join(",") }); }) .on("webrtc/offer", (cmd, _xchg) => { From 25ada8a3f396d60d24745f3c72dc711848b08fda Mon Sep 17 00:00:00 2001 From: Andrew Davis Date: Mon, 19 Jan 2026 12:07:07 -0500 Subject: [PATCH 14/20] chore: clean up broadcast service tests --- nohub/spec/api/sessions.api.spec.ts | 1 - .../spec/broadcast/broadcast.service.spec.ts | 194 ++++++++++-------- nohub/spec/sessions/session.api.spec.ts | 2 - 3 files changed, 109 insertions(+), 88 deletions(-) diff --git a/nohub/spec/api/sessions.api.spec.ts b/nohub/spec/api/sessions.api.spec.ts index 7f2e047..4647803 100644 --- a/nohub/spec/api/sessions.api.spec.ts +++ b/nohub/spec/api/sessions.api.spec.ts @@ -39,5 +39,4 @@ describe("Sessions API", () => { expect(reply.text).not.toBeEmpty(); }); }); - }); diff --git a/nohub/spec/broadcast/broadcast.service.spec.ts b/nohub/spec/broadcast/broadcast.service.spec.ts index 462114f..849e770 100644 --- a/nohub/spec/broadcast/broadcast.service.spec.ts +++ b/nohub/spec/broadcast/broadcast.service.spec.ts @@ -1,101 +1,125 @@ -import { describe, expect, test, beforeEach, mock } from "bun:test"; +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { Games, Lobbies, Sessions } from "@spec/fixtures"; +import { mockSocket } from "@spec/sessions/session.api.spec"; import { BroadcastService } from "@src/broadcast/broadcast.service"; -import { SessionRepository } from "@src/sessions/session.repository"; -import { SessionApi } from "@src/sessions/session.api"; +import { readDefaultConfig } from "@src/config"; import { DataNotFoundError } from "@src/errors"; -import { Sessions, Lobbies, Games } from "@spec/fixtures"; -import type { NohubReactor } from "@src/nohub"; -import { LobbyRepository } from "@src/lobbies/lobby.repository"; -import { GameRepository } from "@src/games/game.repository"; import { NohubEventBus } from "@src/events"; -import { readDefaultConfig } from "@src/config"; -import type { Socket } from "bun"; +import { GameRepository } from "@src/games/game.repository"; +import { LobbyApi } from "@src/lobbies/lobby.api"; +import { LobbyEventBus } from "@src/lobbies/lobby.events"; +import { LobbyRepository } from "@src/lobbies/lobby.repository"; +import { LobbyService } from "@src/lobbies/lobby.service"; +import type { NohubReactor } from "@src/nohub"; import type { SessionData } from "@src/sessions/session"; -import { mockSocket } from "@spec/sessions/session.api.spec"; - -describe("BroadcastService", () => { - let service: BroadcastService; - let reactor: NohubReactor; - let sessionRepository: SessionRepository; - let sessionApi: SessionApi; - - let daveSocket: Socket; - let ericSocket: Socket; - - beforeEach(() => { - reactor = { - send: mock(() => ({}) as any), - } as unknown as NohubReactor; - - sessionRepository = new SessionRepository(); - const lobbyRepository = new LobbyRepository(); - const gameRepository = new GameRepository(); - Games.insert(gameRepository); - - const config = readDefaultConfig().sessions; - config.arbitraryGameId = true; // simplifying for this test - - sessionApi = new SessionApi( - sessionRepository, - lobbyRepository, - gameRepository, - new NohubEventBus(), - config - ); - - // create sessions using API - daveSocket = mockSocket(Sessions.dave.address); - ericSocket = mockSocket(Sessions.eric.address); - - sessionApi.openSession(daveSocket); - sessionApi.openSession(ericSocket); - - service = new BroadcastService(() => reactor, sessionRepository); - }); - - describe("unicast", () => { - test("should send command to session", () => { - const sessionId = daveSocket.data.id; - service.unicast(sessionId, { name: "command" }); - - expect(reactor.send).toHaveBeenCalled(); - // Verify it was called with the correct socket - expect(reactor.send).toHaveBeenCalledWith(daveSocket, { name: "command" }); - }); +import { SessionApi } from "@src/sessions/session.api"; +import { SessionRepository } from "@src/sessions/session.repository"; +import type { Socket } from "bun"; - test("should throw if session not found", () => { - expect(() => service.unicast("unknown", { name: "command" })).toThrow(DataNotFoundError); - }); - }); +let reactor: NohubReactor; - describe("broadcast", () => { - test("should send to all participants", () => { - const daveId = daveSocket.data.id; - const ericId = ericSocket.data.id; +let sessionRepository: SessionRepository; +let sessionApi: SessionApi; +let lobbyRepository: LobbyRepository; +let lobbyService: LobbyService; +let lobbyApi: LobbyApi; - // Create a lobby with participants using the generated IDs - const lobby = { ...Lobbies.davesLobby, participants: [daveId, ericId] }; +let broadcastService: BroadcastService; - service.broadcast(lobby, { name: "command" }); +let daveSocket: Socket; +let ericSocket: Socket; - // Should broadcast to both - expect(reactor.send).toHaveBeenCalledTimes(2); - expect(reactor.send).toHaveBeenCalledWith(daveSocket, { name: "command" }); - expect(reactor.send).toHaveBeenCalledWith(ericSocket, { name: "command" }); - }); +describe("BroadcastService", () => { + beforeEach(() => { + reactor = { + send: mock(() => ({})), + } as unknown as NohubReactor; + + sessionRepository = new SessionRepository(); + const gameLookup = new GameRepository(); + Games.insert(gameLookup); + Sessions.insert(sessionRepository); + + sessionApi = new SessionApi( + sessionRepository, + new LobbyRepository(), + gameLookup, + new NohubEventBus(), + readDefaultConfig().sessions, + ); + + lobbyRepository = new LobbyRepository(); + lobbyService = new LobbyService( + lobbyRepository, + readDefaultConfig().lobbies, + new LobbyEventBus(), + ); + + lobbyApi = new LobbyApi(lobbyRepository, lobbyService, () => undefined); + + Lobbies.insert(lobbyRepository); + + // create sessions using API + daveSocket = mockSocket(Sessions.dave.address); + ericSocket = mockSocket(Sessions.eric.address); + + sessionApi.openSession(daveSocket); + sessionApi.openSession(ericSocket); + + broadcastService = new BroadcastService(() => reactor, sessionRepository); + }); + + describe("unicast", () => { + test("should send command to session", () => { + const sessionId = daveSocket.data.id; + broadcastService.unicast(sessionId, { name: "command" }); + + expect(reactor.send).toHaveBeenCalled(); + // Verify it was called with the correct socket + expect(reactor.send).toHaveBeenCalledWith(daveSocket, { + name: "command", + }); + }); - test("should skip missing sessions", () => { - const daveId = daveSocket.data.id; + test("should throw if session not found", () => { + expect(() => + broadcastService.unicast("unknown", { name: "command" }), + ).toThrow(DataNotFoundError); + }); + }); + + describe("broadcast", () => { + test("should send to all participants", () => { + const lobby = lobbyApi.create(Sessions.dave.address, daveSocket.data); + lobbyApi.join(lobby.id, ericSocket.data); + + broadcastService.broadcast(lobby, { name: "command" }); + + // Should broadcast to both + expect(reactor.send).toHaveBeenCalledTimes(2); + expect(reactor.send).toHaveBeenCalledWith(daveSocket, { + name: "command", + }); + expect(reactor.send).toHaveBeenCalledWith(ericSocket, { + name: "command", + }); + }); - // One participant - const lobby = { ...Lobbies.davesLobby, participants: [daveId] }; + test("should skip missing sessions", () => { + const lobby = lobbyApi.create(Sessions.dave.address, daveSocket.data); - service.broadcast(lobby, { name: "command" }); + // Join + lobbyApi.join(lobby.id, ericSocket.data); + // Close (leave) + sessionApi.closeSession(ericSocket); - // Should broadcast to just 1 particpant - expect(reactor.send).toHaveBeenCalledTimes(1); - expect(reactor.send).toHaveBeenCalledWith(daveSocket, { name: "command" }); + broadcastService.broadcast(lobby, { name: "command" }); - }); + // Should broadcast to just 1 particpant + expect(reactor.send).toHaveBeenCalledTimes(1); + expect(reactor.send).toHaveBeenCalledWith(daveSocket, { + name: "command", + }); }); + }); }); diff --git a/nohub/spec/sessions/session.api.spec.ts b/nohub/spec/sessions/session.api.spec.ts index 6717a65..13809d6 100644 --- a/nohub/spec/sessions/session.api.spec.ts +++ b/nohub/spec/sessions/session.api.spec.ts @@ -105,7 +105,6 @@ describe("SessionApi", () => { ).not.toThrow(); }); - test("should create session with number", () => { // Don't need fixtures sessionRepository.clear(); @@ -117,7 +116,6 @@ describe("SessionApi", () => { sessionApi.openSession(mockSocket(Sessions.dave.address)); expect([...sessionRepository.list()][0].id).toMatch(/^[0-9]+$/); }); - }); describe("setGame", () => { From 7990b65ac472993cb59e904b2da931c83b9bcec0 Mon Sep 17 00:00:00 2001 From: Andrew Davis Date: Mon, 19 Jan 2026 12:19:51 -0500 Subject: [PATCH 15/20] chore: extra readme instructions --- nohub.gd/examples/webrtc/README.md | 52 +++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/nohub.gd/examples/webrtc/README.md b/nohub.gd/examples/webrtc/README.md index 8e9da8d..1308a39 100644 --- a/nohub.gd/examples/webrtc/README.md +++ b/nohub.gd/examples/webrtc/README.md @@ -1,4 +1,54 @@ -# Nohub WebRTC Example +# nohub WebRTC Example + +This example provides a way to use nohub to form WebRTC lobbies and connections. + +### nohub + +- Install bun +- Run nohub +- like these commands for Linux: + +``` +# Install bun +curl -fsSL https://bun.sh/install | bash + +# Clone nohub and enter server directory +git clone https://github.com/foxssake/nohub.git +cd nohub/nohub + +# Install dependencies +bun install + +# Start nohub +bun run dev +``` + +Set up your .env for Nohub to use session id length 9 and set session ids to use numbers. (Ensures that a Godot peer can be created from each session) + +``` +# Shorten ids to be the correct size for Godot +NOHUB_SESSIONS_ID_LENGTH=9 + +# Set to true to create session ids as number +NOHUB_SESSIONS_ID_USE_NUMBER=true +``` + +### Godot Client (/nohub.gd) - Download the latest binary from the releases in the official webrtc-native repo: https://github.com/godotengine/webrtc-native - Unzip into the root of a Godot project (in this case, the `/nohub.gd`, where the .godot folder is located) +- Use the example node provided in `browser_webrtc.tscn` +- Listen to the signals `peer_connected` and `peer_disconnected` to add and remove players +- See docs for [WebRTCMultiplayerPeer](https://docs.godotengine.org/en/4.4/classes/class_webrtcmultiplayerpeer.html#class-webrtcmultiplayerpeer) + +``` +func start_web_rtc(id: String): + web_rtc_peer = WebRTCMultiplayerPeer.new() + web_rtc_peer.create_mesh(int(id)) + multiplayer.multiplayer_peer = web_rtc_peer + + multiplayer.peer_connected.connect(func(id): print(\"Add player: \", id)) + multiplayer.peer_disconnected.connect(func(id): print(\"Remove player: \", id)) +``` + +Replace the `print` statements with game logic From 3d4d74219d333232d707e90e380d66a4caf69826 Mon Sep 17 00:00:00 2001 From: Andrew Davis Date: Mon, 19 Jan 2026 14:39:15 -0500 Subject: [PATCH 16/20] feat: add new lobby leave api and tests to support --- nohub/spec/lobbies/lobby.service.spec.ts | 28 ++++++++++++++++++++++++ nohub/src/lobbies/lobby.api.ts | 9 ++++++++ nohub/src/lobbies/lobby.module.ts | 8 +++++++ nohub/src/lobbies/lobby.service.ts | 16 ++++++++++++-- 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/nohub/spec/lobbies/lobby.service.spec.ts b/nohub/spec/lobbies/lobby.service.spec.ts index 195812d..a5f510a 100644 --- a/nohub/spec/lobbies/lobby.service.spec.ts +++ b/nohub/spec/lobbies/lobby.service.spec.ts @@ -213,6 +213,34 @@ describe("LobbyService", () => { }); }); + describe("leave", () => { + test("should leave lobby", () => { + const lobby = lobbyService.create( + Addresses.dave, + new Map(), + Sessions.dave, + ); + + lobbyService.join(lobby, Sessions.eric); + expect(lobby.participants).toContain(Sessions.eric.id); + + lobbyService.leave(lobby, Sessions.eric); + expect(lobby.participants).not.toContain(Sessions.eric.id); + }); + + test("should throw if not in lobby", () => { + expect(() => + lobbyService.leave(Lobbies.davesLobby, Sessions.eric), + ).toThrow(InvalidCommandError); + }); + + test("should throw if owner tries to leave", () => { + expect(() => + lobbyService.leave(Lobbies.davesLobby, Sessions.dave), + ).toThrow(InvalidCommandError); + }); + }); + describe("setData", () => { test("should replace lobby data", () => { const newData = Lobbies.coolLobby.data; diff --git a/nohub/src/lobbies/lobby.api.ts b/nohub/src/lobbies/lobby.api.ts index cec833e..9809893 100644 --- a/nohub/src/lobbies/lobby.api.ts +++ b/nohub/src/lobbies/lobby.api.ts @@ -88,6 +88,15 @@ export class LobbyApi { return address; } + leave(id: string, session: SessionData): void { + this.logger.info({ session, lobbyId: id }, "Leaving lobby"); + + const lobby = this.lobbyRepository.requireInGame(id, session.gameId); + this.lobbyService.leave(lobby, session); + + this.logger.info({ session, lobby }, "Successfully left lobby"); + } + setData(id: string, data: Map, session: SessionData): void { this.logger.info({ lobbyId: id, session, data }, "Updating session data"); diff --git a/nohub/src/lobbies/lobby.module.ts b/nohub/src/lobbies/lobby.module.ts index e72d53a..9071385 100644 --- a/nohub/src/lobbies/lobby.module.ts +++ b/nohub/src/lobbies/lobby.module.ts @@ -92,6 +92,14 @@ export class LobbyModule implements Module { const address = this.lobbyApi.join(lobbyId, session); xchg.reply({ params: [address] }); }) + .on("lobby/leave", (cmd, xchg) => { + requireRequest(cmd); + const lobbyId = cmd.requireText(); + const session = sessionOf(xchg); + + this.lobbyApi.leave(lobbyId, session); + xchg.reply({ text: "ok" }); + }) .on("lobby/set-data", (cmd, xchg) => { requireRequest(cmd); const lobbyId = requireSingleParam(cmd, "Missing lobby ID!"); diff --git a/nohub/src/lobbies/lobby.service.ts b/nohub/src/lobbies/lobby.service.ts index 5356ea9..f1e8c27 100644 --- a/nohub/src/lobbies/lobby.service.ts +++ b/nohub/src/lobbies/lobby.service.ts @@ -90,11 +90,23 @@ export class LobbyService { requireLobbyJoinable(lobby, session); lobby.participants.push(session.id); this.repository.update(lobby); - // TODO: Leave command - // TODO: Listen to session close event to remove session from lobbies + return lobby.address; } + leave(lobby: Lobby, session: SessionData) { + if (lobby.owner === session.id) + throw new InvalidCommandError("Owner can't leave lobby!"); + + const index = lobby.participants.indexOf(session.id); + if (index < 0) + throw new InvalidCommandError("Session is not in the lobby!"); + + lobby.participants.splice(index, 1); + this.repository.update(lobby); + this.eventBus.emit("lobby-change", lobby, lobby); + } + setData( lobby: Lobby, data: Map, From 9342fbf561abc48dd3ab9d8873772a7d073c48bf Mon Sep 17 00:00:00 2001 From: Andrew Davis Date: Tue, 20 Jan 2026 22:41:12 -0500 Subject: [PATCH 17/20] chore: remove web-rtc specific example to reduce scope --- nohub.gd/.gitignore | 1 - nohub.gd/addons/nohub.gd/nohub.gd | 2 - nohub.gd/addons/nohub.gd/nohub_client.gd | 125 +--- nohub.gd/addons/nohub.gd/result.gd | 18 - nohub.gd/examples/webrtc/README.md | 54 -- nohub.gd/examples/webrtc/browser_webrtc.tscn | 696 ------------------- nohub/.env.example | 4 +- nohub/src/signaling/signaling.module.ts | 81 --- 8 files changed, 14 insertions(+), 967 deletions(-) delete mode 100644 nohub.gd/examples/webrtc/README.md delete mode 100644 nohub.gd/examples/webrtc/browser_webrtc.tscn delete mode 100644 nohub/src/signaling/signaling.module.ts diff --git a/nohub.gd/.gitignore b/nohub.gd/.gitignore index b2e7e94..288bf50 100644 --- a/nohub.gd/.gitignore +++ b/nohub.gd/.gitignore @@ -2,4 +2,3 @@ .godot/ build/ -webrtc/ \ No newline at end of file diff --git a/nohub.gd/addons/nohub.gd/nohub.gd b/nohub.gd/addons/nohub.gd/nohub.gd index 4593ac7..5af87f6 100644 --- a/nohub.gd/addons/nohub.gd/nohub.gd +++ b/nohub.gd/addons/nohub.gd/nohub.gd @@ -1,4 +1,2 @@ @tool extends EditorPlugin - - diff --git a/nohub.gd/addons/nohub.gd/nohub_client.gd b/nohub.gd/addons/nohub.gd/nohub_client.gd index 7157648..9efa88e 100644 --- a/nohub.gd/addons/nohub.gd/nohub_client.gd +++ b/nohub.gd/addons/nohub.gd/nohub_client.gd @@ -30,6 +30,7 @@ class_name NohubClient var _connection: StreamPeerTCP var _reactor: TrimsockTCPClientReactor + ## Construct a client using the specified [param connection] func _init(connection: StreamPeerTCP): _connection = connection @@ -37,7 +38,6 @@ func _init(connection: StreamPeerTCP): _reactor = TrimsockTCPClientReactor.new(connection) - ## Poll the client ## [br][br] ## This will poll the underlying connection and process any incoming commands. @@ -48,13 +48,13 @@ func poll() -> void: ## [br][br] ## See [url=https://foxssake.github.io/nohub/understanding-nohub/concepts.html#games]Games[/url]. func set_game(id: String) -> NohubResult: - var request := TrimsockCommand.request("session/set-game") \ + var request := TrimsockCommand.request("session/set-game")\ .with_params([id]) return await _bool_request(request) ## Create a lobby func create_lobby(address: String, data: Dictionary = {}) -> NohubResult.Lobby: - var request := TrimsockCommand.request("lobby/create") \ + var request := TrimsockCommand.request("lobby/create")\ .with_params([address]) for key in data: request.with_kv_pairs([TrimsockCommand.pair_of(key, data[key])]) @@ -72,7 +72,7 @@ func create_lobby(address: String, data: Dictionary = {}) -> NohubResult.Lobby: ## If [param properties] is specified, only the listed properties will be ## returned from the lobby's custom data. func get_lobby(id: String, properties: Array[String] = []) -> NohubResult.Lobby: - var request := TrimsockCommand.request("lobby/get") \ + var request := TrimsockCommand.request("lobby/get")\ .with_params([id] + properties) var xchg := _reactor.submit_request(request) var response := await xchg.read() @@ -88,7 +88,7 @@ func get_lobby(id: String, properties: Array[String] = []) -> NohubResult.Lobby: ## returned from the lobby's custom data. func list_lobbies(properties: Array[String] = []) -> NohubResult.LobbyList: var result := [] as Array[NohubLobby] - var request := TrimsockCommand.request("lobby/list") \ + var request := TrimsockCommand.request("lobby/list")\ .with_params(properties) var xchg := _reactor.submit_request(request) @@ -108,7 +108,7 @@ func list_lobbies(properties: Array[String] = []) -> NohubResult.LobbyList: ## [br][br] ## Only the lobby's owner can delete the lobby. func delete_lobby(lobby_id: String) -> NohubResult: - var request := TrimsockCommand.request("lobby/delete") \ + var request := TrimsockCommand.request("lobby/delete")\ .with_params([lobby_id]) return await _bool_request(request) @@ -117,7 +117,7 @@ func delete_lobby(lobby_id: String) -> NohubResult: ## The response will contain the lobby's address. This string can be used to ## connect. func join_lobby(lobby_id: String) -> NohubResult.Address: - var request := TrimsockCommand.request("lobby/join") \ + var request := TrimsockCommand.request("lobby/join")\ .with_params([lobby_id]) var xchg := _reactor.submit_request(request) @@ -132,7 +132,7 @@ func join_lobby(lobby_id: String) -> NohubResult.Address: ## [br][br] ## Only the lobby's owner can lock the lobby. func lock_lobby(lobby_id: String) -> NohubResult: - var request := TrimsockCommand.request("lobby/lock") \ + var request := TrimsockCommand.request("lobby/lock")\ .with_params([lobby_id]) return await _bool_request(request) @@ -140,7 +140,7 @@ func lock_lobby(lobby_id: String) -> NohubResult: ## [br][br] ## Only the lobby's owner can unlock the lobby. Lobbies are unlocked by default. func unlock_lobby(lobby_id: String) -> NohubResult: - var request := TrimsockCommand.request("lobby/unlock") \ + var request := TrimsockCommand.request("lobby/unlock")\ .with_params([lobby_id]) return await _bool_request(request) @@ -148,7 +148,7 @@ func unlock_lobby(lobby_id: String) -> NohubResult: ## [br][br] ## Only the lobby's owner can hide the lobby. func hide_lobby(lobby_id: String) -> NohubResult: - var request := TrimsockCommand.request("lobby/hide") \ + var request := TrimsockCommand.request("lobby/hide")\ .with_params([lobby_id]) return await _bool_request(request) @@ -156,7 +156,7 @@ func hide_lobby(lobby_id: String) -> NohubResult: ## [br][br] ## Only the lobby's owner can hide the lobby. Lobbies are visible by default. func publish_lobby(lobby_id: String) -> NohubResult: - var request := TrimsockCommand.request("lobby/publish") \ + var request := TrimsockCommand.request("lobby/publish")\ .with_params([lobby_id]) return await _bool_request(request) @@ -165,8 +165,8 @@ func publish_lobby(lobby_id: String) -> NohubResult: ## Note that this method updates the data, instead of adding to it. Only the ## lobby's owner can update the lobby's custom data. func set_lobby_data(lobby_id: String, data: Dictionary) -> NohubResult: - var request := TrimsockCommand.request("lobby/set-data") \ - .with_params([lobby_id]) \ + var request := TrimsockCommand.request("lobby/set-data")\ + .with_params([lobby_id])\ .with_kv_map(data) return await _bool_request(request) @@ -207,102 +207,3 @@ func _command_to_error(command: TrimsockCommand) -> NohubResult: return NohubResult.of_error(command.params[0], command.params[1]) else: return NohubResult.of_error(command.name, "") - -#region WebRTC - -## Emitted during "webrtc/start" to kick off the peer connection -signal signal_webrtc_create_new_peer_connection(id) -## Emitted to exchange offers and answers to finish establishing connections -signal signal_webrtc_message(type, data) - -## Setup the events for negotiating WebRTC connections using the signalling module -## [br][br] -## When a lobby host starts a lobby (or a peer asks for an offer, answer, or candidate): -## these events will emit signals that the clients use to initialize WebRTC peer connections -## NOTE: This function is based on the _setup_reactor example in this file: -## https://github.com/foxssake/trimsock/blob/main/trimsock.gd/examples/server/server.gd -func setup_webrtc_reactor() -> void: - _reactor.on("webrtc/start", func(_cmd: TrimsockCommand, xchg: TrimsockExchange): - var players = _cmd.kv_map['players'] as String - for peer_id in players.split(',', false): - signal_webrtc_create_new_peer_connection.emit(int(peer_id.strip_edges())) - ).on("webrtc/get/offer", func(_cmd, xchg: TrimsockExchange): - signal_webrtc_message.emit(WEBRTC_ACTION.Offer, _cmd) - ).on("webrtc/get/answer", func(_cmd, xchg: TrimsockExchange): - signal_webrtc_message.emit(WEBRTC_ACTION.Answer, _cmd) - ).on("webrtc/get/candidate", func(_cmd, xchg: TrimsockExchange): - signal_webrtc_message.emit(WEBRTC_ACTION.Candidate, _cmd) - ).on_unknown(func(cmd, xchg: TrimsockExchange): - _log("[srv] Unknown command: %s" % cmd) - return TrimsockCommand.error_from(cmd, "error", ["Unknown command", cmd.name]) - ) - -func _log(what: String) -> void: - prints(what) - -## Start lobby, kicking off joining -## [br][br] -## Only the lobby's owner can start the lobby. -func start_lobby(lobby_id: String) -> NohubResult.LobbyMessage: - var request := TrimsockCommand.request("webrtc/lobby/start") \ - .with_params([lobby_id]) - - var xchg := _reactor.submit_request(request) - var response := await xchg.read() - - if response.is_success(): - return NohubResult.LobbyMessage.of_value(response.params[0]) - else: - return _command_to_error(response) - -## Leave the lobby -## [br][br] -func leave_lobby(lobby_id: String) -> NohubResult.LobbyMessage: - var request := TrimsockCommand.request("lobby/leave") \ - .with_params([lobby_id]) - - var xchg := _reactor.submit_request(request) - var response := await xchg.read() - - if response.is_success(): - return NohubResult.LobbyMessage.of_value(response.params[0]) - else: - return _command_to_error(response) - -func get_session() -> String: - var request := TrimsockCommand.request("getid") - var xchg := _reactor.submit_request(request) - var response := await xchg.read() - if response.is_success(): - return response.text - else: - return "" - -enum WEBRTC_ACTION { - Offer, - Answer, - Candidate -} - -func send_webrtc_message(type: WEBRTC_ACTION, id: String, data: Dictionary = {}) -> NohubResult: - var request - - match type: - WEBRTC_ACTION.Offer: - request = TrimsockCommand.request("webrtc/offer").with_params([id]) - WEBRTC_ACTION.Answer: - request = TrimsockCommand.request("webrtc/answer").with_params([id]) - WEBRTC_ACTION.Candidate: - request = TrimsockCommand.request("webrtc/candidate").with_params([id]) - - request.with_kv_map(data) - - var xchg := _reactor.submit_request(request) - var response := await xchg.read() - - if response.is_success(): - return NohubResult.LobbyMessage.of_value('send success') - else: - return _command_to_error(response) - -#endregion diff --git a/nohub.gd/addons/nohub.gd/result.gd b/nohub.gd/addons/nohub.gd/result.gd index 13f141a..40f909e 100644 --- a/nohub.gd/addons/nohub.gd/result.gd +++ b/nohub.gd/addons/nohub.gd/result.gd @@ -69,24 +69,6 @@ class LobbyList extends NohubResult: else: return [] -## Stores a LobbyMessage on success -## -## See [NohubResult] for details. -class LobbyMessage extends NohubResult: - ## Construct a successful result object with the given [param value] - static func of_value(value: String) -> LobbyMessage: - var result := LobbyMessage.new() - result._is_success = true - result._value = value - return result - - ## Get the resulting string - func value() -> String: - if _is_success: - return _value as String - else: - return "" - ## Stores an address string on success ## ## See [NohubResult] for details. diff --git a/nohub.gd/examples/webrtc/README.md b/nohub.gd/examples/webrtc/README.md deleted file mode 100644 index 1308a39..0000000 --- a/nohub.gd/examples/webrtc/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# nohub WebRTC Example - -This example provides a way to use nohub to form WebRTC lobbies and connections. - -### nohub - -- Install bun -- Run nohub -- like these commands for Linux: - -``` -# Install bun -curl -fsSL https://bun.sh/install | bash - -# Clone nohub and enter server directory -git clone https://github.com/foxssake/nohub.git -cd nohub/nohub - -# Install dependencies -bun install - -# Start nohub -bun run dev -``` - -Set up your .env for Nohub to use session id length 9 and set session ids to use numbers. (Ensures that a Godot peer can be created from each session) - -``` -# Shorten ids to be the correct size for Godot -NOHUB_SESSIONS_ID_LENGTH=9 - -# Set to true to create session ids as number -NOHUB_SESSIONS_ID_USE_NUMBER=true -``` - -### Godot Client (/nohub.gd) - -- Download the latest binary from the releases in the official webrtc-native repo: https://github.com/godotengine/webrtc-native -- Unzip into the root of a Godot project (in this case, the `/nohub.gd`, where the .godot folder is located) -- Use the example node provided in `browser_webrtc.tscn` -- Listen to the signals `peer_connected` and `peer_disconnected` to add and remove players -- See docs for [WebRTCMultiplayerPeer](https://docs.godotengine.org/en/4.4/classes/class_webrtcmultiplayerpeer.html#class-webrtcmultiplayerpeer) - -``` -func start_web_rtc(id: String): - web_rtc_peer = WebRTCMultiplayerPeer.new() - web_rtc_peer.create_mesh(int(id)) - multiplayer.multiplayer_peer = web_rtc_peer - - multiplayer.peer_connected.connect(func(id): print(\"Add player: \", id)) - multiplayer.peer_disconnected.connect(func(id): print(\"Remove player: \", id)) -``` - -Replace the `print` statements with game logic diff --git a/nohub.gd/examples/webrtc/browser_webrtc.tscn b/nohub.gd/examples/webrtc/browser_webrtc.tscn deleted file mode 100644 index ebae446..0000000 --- a/nohub.gd/examples/webrtc/browser_webrtc.tscn +++ /dev/null @@ -1,696 +0,0 @@ -[gd_scene load_steps=10 format=3 uid="uid://4n0eqw25becg"] - -[ext_resource type="Texture2D" uid="uid://j2fymrfv4c3r" path="res://resources/icons/lock.svg" id="1_47dd4"] -[ext_resource type="Texture2D" uid="uid://clqecwtwyaubp" path="res://resources/icons/join.svg" id="1_avfqc"] -[ext_resource type="Texture2D" uid="uid://6a7vvotwb3rt" path="res://resources/icons/delete.svg" id="1_c5nld"] -[ext_resource type="Texture2D" uid="uid://bdrspgruqn4ft" path="res://resources/icons/hide.svg" id="1_s7umg"] - -[sub_resource type="GDScript" id="GDScript_xkt7l"] -script/source = "extends Control - -@onready var host_input: LineEdit = %\"Host Input\" -@onready var connect_button: Button = %\"Connect Button\" -@onready var disconnect_button: Button = %\"Disconnect Button\" -@onready var status_label: Label = %\"Status Label\" - -@onready var game_id_input: LineEdit = %\"Game ID Input\" -@onready var set_game_id_button: Button = %\"Set Game ID Button\" - -@onready var lobby_list: ItemList = %\"Lobby List\" - -@onready var create_name_input: LineEdit = %\"Lobby Name Input\" -@onready var create_button: Button = %\"Create Lobby Button\" -@onready var address_label: Label = %\"Address Label\" -@onready var selected_lobby_label: Label = %\"Selected Lobby Label\" - -@onready var lock_lobby_button: Button = %\"Lock Lobby Button\" -@onready var hide_lobby_button: Button = %\"Hide Lobby Button\" -@onready var join_lobby_button: Button = %\"Join Lobby Button\" -@onready var delete_lobby_button: Button = %\"Delete Lobby Button\" -@onready var leave_lobby_button: Button = %\"Leave Lobby Button\" -@onready var start_lobby_button: Button = %\"Start Lobby Button\" - -@onready var modal: Popup = %Modal -@onready var modal_message_label: Label = %\"Message Label\" -@onready var modal_confirm_button: Button = %\"Confirm Button\" - -var lock_icon := preload(\"res://resources/icons/lock.svg\") as Texture2D -var unlock_icon := preload(\"res://resources/icons/unlock.svg\") as Texture2D -var hide_icon := preload(\"res://resources/icons/hide.svg\") as Texture2D -var publish_icon := preload(\"res://resources/icons/publish.svg\") as Texture2D - -var list_interval: float = 2.0 -var time: float = 0.0 - - -var _connection: StreamPeerTCP = null -var _client: NohubClient = null -var _last_list: float = -1. -var _lobbies_in_list: Array[NohubLobby] = [] - -var _selected_lobby: NohubLobby = null -var _lobby_list_columns = [\"ID\", \"Name\", \"Locked?\", \"Hidden?\", \"Data\"] - -func set_selected_lobby(lobby: NohubLobby) -> void: - _selected_lobby = lobby - if is_instance_valid(lobby): - selected_lobby_label.text = lobby.id - lock_lobby_button.text = \"Unlock\" if lobby.is_locked else \"Lock\" - lock_lobby_button.icon = unlock_icon if lobby.is_locked else lock_icon - - hide_lobby_button.text = \"Hide\" if lobby.is_visible else \"Publish\" - hide_lobby_button.icon = hide_icon if lobby.is_visible else publish_icon - else: - selected_lobby_label.text = \"\" - lock_lobby_button.text = \"Lock\" - lock_lobby_button.icon = lock_icon - hide_lobby_button.text = \"Hide\" - hide_lobby_button.icon = hide_icon - -func get_selected_lobby() -> NohubLobby: - return _selected_lobby - -func popup(text: String) -> void: - modal_message_label.text = text - modal.show() - -func _ready(): - connect_button.pressed.connect(_connect) - disconnect_button.pressed.connect(_disconnect) - create_button.pressed.connect(_create_lobby) - set_game_id_button.pressed.connect(_set_game_id) - lobby_list.set_select_mode(ItemList.SELECT_MULTI) - lobby_list.multi_selected.connect(_select_lobby) - lobby_list.max_columns = _lobby_list_columns.size() - - join_lobby_button.pressed.connect(_join_lobby) - lock_lobby_button.pressed.connect(_lock_lobby) - hide_lobby_button.pressed.connect(_hide_lobby) - delete_lobby_button.pressed.connect(_delete_lobby) - leave_lobby_button.pressed.connect(_leave_lobby) - start_lobby_button.pressed.connect(_start_lobby) - - modal_confirm_button.pressed.connect(func(): - modal.hide() - ) - -func _process(dt: float) -> void: - time += dt - status_label.text = \"None\" - - if _connection != null: - match _connection.get_status(): - StreamPeerTCP.STATUS_NONE: status_label.text = \"None\" - StreamPeerTCP.STATUS_CONNECTING: status_label.text = \"Connecting...\" - StreamPeerTCP.STATUS_CONNECTED: status_label.text = \"Connected\" - StreamPeerTCP.STATUS_ERROR: status_label.text = \"Error\" - - if _connection.get_status() == StreamPeerTCP.STATUS_CONNECTED and address_label.text == \"\": - # Get IP - address_label.text = \"...\" - var ip := await _get_ip() - address_label.text = ip - - _connection.poll() - - if _client != null: - _client.poll() - - if time >= _last_list + list_interval: - _update_list() - _last_list = time - - if ws_peer_id == null: - _get_session_id() - -func _connect() -> void: - var input := host_input.text - var host := input - var port := 9980 - - if input.contains(\":\"): - var at := input.rfind(\":\") - host = input.substr(0, at) - port = int(input.substr(at + 1)) - - print(\"Connecting to nohub at %s:%d\" % [host, port]) - - _connection = StreamPeerTCP.new() - var err := _connection.connect_to_host(host, port) - if err != OK: - popup(\"Couldn't connect to nohub at %s:%d - %s\" % [host, port, error_string(err)]) - return - - _client = NohubClient.new(_connection) - _client.setup_webrtc_reactor() - -func _disconnect() -> void: - if _connection == null: - return - - print(\"Disconnecting from host\") - _connection.disconnect_from_host() - _connection = null - _client = null - - lobby_list.clear() - _lobbies_in_list.clear() - address_label.text = \"\" - -func _create_lobby() -> void: - if not _client: return - - var name_text := create_name_input.text - var lobby_response := await _client.create_lobby(\"enet://\" + address_label.text, { \"name\": name_text }) - if not lobby_response.is_success(): - popup(\"Failed to create lobby!\\n%s\" % [lobby_response]) - return - - var lobby := lobby_response.value() - print(\"Created lobby: %s\" % lobby) - - # We could have submitted data at create, this is to excercise the `set_data()` call - var data := lobby.data.duplicate() - data.merge({ \"created-at\": Time.get_datetime_string_from_system() }) - var set_response := await _client.set_lobby_data(lobby.id, data) - if not set_response.is_success(): - print(\"Failed to set lobby data: %s\" % [set_response]) - - create_name_input.text = \"\" - _update_list() - -func _set_game_id() -> void: - if not _client: return - - var game_id := game_id_input.text - var response := await _client.set_game(game_id) - - if not response.is_success(): - popup(\"Failed to set game ID to %s!\\n%s\" % [game_id, response]) - else: - print(\"Game ID set to %s\" % [game_id]) - - _update_list() - -func _select_lobby(clicked_idx: int, _selected: bool = true) -> void: - @warning_ignore(\"integer_division\") - var row_idx := clicked_idx / lobby_list.max_columns - 1 - if row_idx < 0: - # User clicked header row, do nothing - return - - var lobby := _lobbies_in_list[row_idx] - set_selected_lobby(lobby) - - # highlight the whole row using multiselect - for i in lobby_list.max_columns: - lobby_list.select(((row_idx + 1) * lobby_list.max_columns) + i, false) - -func _join_lobby() -> void: - if not _client: return - - var lobby := get_selected_lobby() - if not is_instance_valid(lobby): - # No lobby selected! - return - - var response := await _client.join_lobby(lobby.id) - if not response.is_success(): - popup(\"Failed to join lobby #%s!\\n%s\" % [lobby.id, response]) - return - - var address := response.value() - popup(\"Lobby address: %s\" % [address]) - -func _lock_lobby(): - if not _client: return - - var lobby := get_selected_lobby() - if not is_instance_valid(lobby): - # No lobby selected! - return - - var response: NohubResult - if lobby.is_locked: - response = await _client.unlock_lobby(lobby.id) - else: - response = await _client.lock_lobby(lobby.id) - - if not response.is_success(): - popup(\"Failed to %s lobby!\\n%s\" % [\"unlock\" if lobby.is_locked else \"lock\", response]) - else: - _update_list() - -func _hide_lobby(): - if not _client: return - - var lobby := get_selected_lobby() - if not is_instance_valid(lobby): - # No lobby selected! - return - - var response: NohubResult - if lobby.is_visible: - response = await _client.hide_lobby(lobby.id) - else: - response = await _client.publish_lobby(lobby.id) - - if not response.is_success(): - popup(\"Failed to %s lobby!\\n%s\" % [\"hide\" if lobby.is_visible else \"publish\", response]) - else: - _update_list() - -func _delete_lobby(): - if not _client: return - - var lobby := get_selected_lobby() - if not is_instance_valid(lobby): - # No lobby selected! - return - - var response := await _client.delete_lobby(lobby.id) - if not response.is_success(): - popup(\"Failed to delete lobby #%s!\\n%s\" % [lobby.id, response]) - else: - _update_list() - -func _update_list() -> void: - if _client == null: return - - var response := await _client.list_lobbies() - if not response.is_success(): - push_warning(\"Failed to update lobby list! %s\" % [response]) - return - - var lobbies := response.value() - print(\"Received lobbies: %s\" % [lobbies]) - - lobby_list.clear() - - # Add header - if lobby_list.item_count == 0: - for lobby_header in _lobby_list_columns: - lobby_list.add_item(lobby_header, null, false) - - # Add lobbies - var prev_selected_lobby = get_selected_lobby() - set_selected_lobby(null) - for lobby in lobbies: - lobby_list.add_item(lobby.id) - lobby_list.add_item(lobby.data.get(\"name\", \"\")) - lobby_list.add_item(\"Locked\" if lobby.is_locked else \"Open\", lock_icon if lobby.is_locked else unlock_icon) - lobby_list.add_item(\"Hidden\" if not lobby.is_visible else \"Public\", hide_icon if not lobby.is_visible else publish_icon) - lobby_list.add_item(JSON.stringify(lobby.data)) - _update_list_with_selected_lobby(lobby, prev_selected_lobby, lobby_list.item_count - 1) - - _lobbies_in_list.assign(lobbies) - -func _update_list_with_selected_lobby(lobby: NohubLobby, prev_selected: NohubLobby, lobby_list_index: int) -> void: - if prev_selected != null: - var search_id := prev_selected.id - if lobby.id == search_id: - set_selected_lobby(lobby) - _select_lobby(lobby_list_index) - -func _get_ip() -> String: - if not _client: return _get_ip_local() - - var ip := await _client.whereami() - if not ip: ip = _get_ip_local() - - return ip - -func _get_ip_local() -> String: - return IP.get_local_addresses()[0] - -#region WebRTCMultiplayerPeer - -var web_rtc_peer: WebRTCMultiplayerPeer -var ws_peer_id = null - -# Patterned [stun:URI, turn:URI]. Use cloudflare default for free unlimited STUN -var STUN_SERVER_URLS = ['stun:stun.cloudflare.com'] -# No TURN. Relay fallback not supported if empty and not included below -var TURN_SERVER_URLS = [] -var ICE_SERVERS = {\"iceServers\": [ {\"urls\": STUN_SERVER_URLS}]} - -func _get_session_id() -> void: - if not _client: return - - var session_id := await _client.get_session() - if not session_id: - push_error('Error: No session') - - ws_peer_id = session_id - _client.signal_webrtc_create_new_peer_connection.connect(_network_create_new_peer_connection) - _client.signal_webrtc_message.connect(_network_process_signal) - start_web_rtc(session_id) - -func start_web_rtc(id: String): - web_rtc_peer = WebRTCMultiplayerPeer.new() - web_rtc_peer.create_mesh(int(id)) - multiplayer.multiplayer_peer = web_rtc_peer - - multiplayer.peer_connected.connect(func(id): print(\"Add player: \", id)) - multiplayer.peer_disconnected.connect(func(id): print(\"Remove player: \", id)) - -func _start_lobby(): - if not _client: return - - var lobby := get_selected_lobby() - if not is_instance_valid(lobby): - # No lobby selected! - return - - var response := await _client.start_lobby(lobby.id) - if not response.is_success(): - popup(\"Failed to start lobby #%s!\\n%s\" % [lobby.id, response]) - else: - var peer_list := response.value() - # CRITICAL: Blocking: this only triggers for the session owner, so they get the list... - popup('Staring with players: ' + peer_list) - await get_tree().create_timer(1.0).timeout - %Modal.hide() - _update_list() - -func _leave_lobby() -> void: - if not _client: return - - var lobby := get_selected_lobby() - if not is_instance_valid(lobby): - # No lobby selected! - return - - var response := await _client.leave_lobby(lobby.id) - if not response.is_success(): - popup(\"Failed to leave lobby #%s!\\n%s\" % [lobby.id, response]) - return - - var address := response.value() - popup(\"Lobby address: %s\" % [address]) - -func _network_create_new_peer_connection(id: int): - if id != int(ws_peer_id): - var new_peer_connection: WebRTCPeerConnection = WebRTCPeerConnection.new() - - new_peer_connection.initialize(ICE_SERVERS) - print(\"binding id \" + str(id) + \" my id is \" + str(ws_peer_id)) - - new_peer_connection.session_description_created.connect(self._offerCreated.bind(id)) - new_peer_connection.ice_candidate_created.connect(self._iceCandidateCreated.bind(id)) - web_rtc_peer.add_peer(new_peer_connection, id) - if id < web_rtc_peer.get_unique_id(): - new_peer_connection.create_offer() - -func _network_process_signal(type: NohubClient.WEBRTC_ACTION, message: TrimsockCommand): - match type: - NohubClient.WEBRTC_ACTION.Offer: - web_rtc_peer.get_peer(int(message.kv_map.orgPeer)).connection.set_remote_description(\"offer\", message.kv_map.data) - NohubClient.WEBRTC_ACTION.Answer: - web_rtc_peer.get_peer(int(message.kv_map.orgPeer)).connection.set_remote_description(\"answer\", message.kv_map.data) - NohubClient.WEBRTC_ACTION.Candidate: - web_rtc_peer.get_peer(int(message.kv_map.orgPeer)).connection.add_ice_candidate(str(message.kv_map.mid), int(message.kv_map.index), message.kv_map.sdp) - -func _offerCreated(type, data, id: int): - web_rtc_peer.get_peer(id).connection.set_local_description(type, data) - - if type == \"offer\": - _sendOffer(id, data) - else: - _sendAnswer(id, data) - -func _sendOffer(id: int, data): - var message: Dictionary = { - \"peer\": str(id), - \"orgPeer\": ws_peer_id, - \"data\": data, - } - - _client.send_webrtc_message(_client.WEBRTC_ACTION.Offer, str(id), message) - -func _sendAnswer(id: int, data): - var message = { - \"peer\": str(id), - \"orgPeer\": ws_peer_id, - \"data\": data, - } - _client.send_webrtc_message(_client.WEBRTC_ACTION.Answer, str(id), message) - -func _iceCandidateCreated(midName, indexName, sdpName, id: int): - var message = { - \"peer\": str(id), - \"orgPeer\": ws_peer_id, - \"mid\": midName, - \"index\": str(indexName), - \"sdp\": sdpName, - } - _client.send_webrtc_message(_client.WEBRTC_ACTION.Candidate, str(id), message) - -#endregion -" - -[sub_resource type="Gradient" id="Gradient_xtbls"] -offsets = PackedFloat32Array(0.47234, 0.475177) -colors = PackedColorArray(0.87451, 0.513726, 0, 1, 0.854902, 0.470588, 0, 0) - -[sub_resource type="GradientTexture2D" id="GradientTexture2D_831n5"] -gradient = SubResource("Gradient_xtbls") -fill = 1 -fill_from = Vector2(0.5, 0.5) - -[sub_resource type="Gradient" id="Gradient_ney1d"] -offsets = PackedFloat32Array(0.469504, 0.470922) -colors = PackedColorArray(0, 0.52549, 0, 1, 0, 0.482353, 0, 0) - -[sub_resource type="GradientTexture2D" id="GradientTexture2D_d1rvb"] -gradient = SubResource("Gradient_ney1d") -fill = 1 -fill_from = Vector2(0.5, 0.5) - -[node name="BrowserWebRTC" type="Control"] -layout_mode = 3 -anchors_preset = 15 -anchor_right = 1.0 -anchor_bottom = 1.0 -grow_horizontal = 2 -grow_vertical = 2 -script = SubResource("GDScript_xkt7l") - -[node name="Browser UI" type="HBoxContainer" parent="."] -layout_mode = 1 -anchors_preset = 15 -anchor_right = 1.0 -anchor_bottom = 1.0 -grow_horizontal = 2 -grow_vertical = 2 - -[node name="Left Panel" type="PanelContainer" parent="Browser UI"] -layout_mode = 2 - -[node name="VBoxContainer" type="VBoxContainer" parent="Browser UI/Left Panel"] -layout_mode = 2 - -[node name="Host Input" type="LineEdit" parent="Browser UI/Left Panel/VBoxContainer"] -unique_name_in_owner = true -layout_mode = 2 -text = "0.0.0.0" -placeholder_text = "Address" - -[node name="Buttons Container" type="HBoxContainer" parent="Browser UI/Left Panel/VBoxContainer"] -layout_mode = 2 - -[node name="Connect Button" type="Button" parent="Browser UI/Left Panel/VBoxContainer/Buttons Container"] -unique_name_in_owner = true -layout_mode = 2 -text = "Connect" - -[node name="Disconnect Button" type="Button" parent="Browser UI/Left Panel/VBoxContainer/Buttons Container"] -unique_name_in_owner = true -layout_mode = 2 -text = "Disconnect" - -[node name="Status Container" type="HBoxContainer" parent="Browser UI/Left Panel/VBoxContainer"] -layout_mode = 2 - -[node name="Status Prefix" type="Label" parent="Browser UI/Left Panel/VBoxContainer/Status Container"] -layout_mode = 2 -text = "Status:" - -[node name="Status Label" type="Label" parent="Browser UI/Left Panel/VBoxContainer/Status Container"] -unique_name_in_owner = true -layout_mode = 2 -size_flags_horizontal = 3 -text = "None" - -[node name="HSeparator" type="HSeparator" parent="Browser UI/Left Panel/VBoxContainer"] -layout_mode = 2 - -[node name="Game ID Input" type="LineEdit" parent="Browser UI/Left Panel/VBoxContainer"] -unique_name_in_owner = true -layout_mode = 2 -placeholder_text = "Game ID" - -[node name="Set Game ID Button" type="Button" parent="Browser UI/Left Panel/VBoxContainer"] -unique_name_in_owner = true -layout_mode = 2 -text = "Set Game ID" - -[node name="HSeparator2" type="HSeparator" parent="Browser UI/Left Panel/VBoxContainer"] -layout_mode = 2 - -[node name="Address Prefix" type="Label" parent="Browser UI/Left Panel/VBoxContainer"] -layout_mode = 2 -text = "Your address:" - -[node name="Address Label" type="Label" parent="Browser UI/Left Panel/VBoxContainer"] -unique_name_in_owner = true -layout_mode = 2 - -[node name="VBoxContainer" type="VBoxContainer" parent="Browser UI"] -layout_mode = 2 -size_flags_horizontal = 3 - -[node name="List Panel" type="ScrollContainer" parent="Browser UI/VBoxContainer"] -layout_mode = 2 -size_flags_horizontal = 3 -size_flags_vertical = 3 - -[node name="Lobby List" type="ItemList" parent="Browser UI/VBoxContainer/List Panel"] -unique_name_in_owner = true -layout_mode = 2 -size_flags_horizontal = 3 -size_flags_vertical = 3 -max_text_lines = 4 -auto_height = true -same_column_width = true -fixed_column_width = 160 -fixed_icon_size = Vector2i(20, 20) - -[node name="Tools" type="HBoxContainer" parent="Browser UI/VBoxContainer"] -layout_mode = 2 - -[node name="Create Container" type="PanelContainer" parent="Browser UI/VBoxContainer/Tools"] -layout_mode = 2 -size_flags_horizontal = 3 - -[node name="VBoxContainer" type="VBoxContainer" parent="Browser UI/VBoxContainer/Tools/Create Container"] -layout_mode = 2 - -[node name="Lobby Name Label" type="Label" parent="Browser UI/VBoxContainer/Tools/Create Container/VBoxContainer"] -layout_mode = 2 -text = "Lobby name: " - -[node name="Lobby Name Input" type="LineEdit" parent="Browser UI/VBoxContainer/Tools/Create Container/VBoxContainer"] -unique_name_in_owner = true -layout_mode = 2 -size_flags_horizontal = 3 - -[node name="Create Lobby Button" type="Button" parent="Browser UI/VBoxContainer/Tools/Create Container/VBoxContainer"] -unique_name_in_owner = true -layout_mode = 2 -size_flags_horizontal = 4 -text = "Create Lobby" - -[node name="Tools Container" type="PanelContainer" parent="Browser UI/VBoxContainer/Tools"] -layout_mode = 2 -size_flags_horizontal = 3 - -[node name="Rows" type="VBoxContainer" parent="Browser UI/VBoxContainer/Tools/Tools Container"] -layout_mode = 2 - -[node name="Selected Lobby Row" type="HBoxContainer" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows"] -layout_mode = 2 - -[node name="Selected Lobby Prefix" type="Label" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows/Selected Lobby Row"] -layout_mode = 2 -text = "Selected Lobby: " - -[node name="Selected Lobby Label" type="Label" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows/Selected Lobby Row"] -unique_name_in_owner = true -layout_mode = 2 - -[node name="Tools Row" type="HBoxContainer" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows"] -layout_mode = 2 -size_flags_vertical = 10 - -[node name="Lock Lobby Button" type="Button" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows/Tools Row"] -unique_name_in_owner = true -layout_mode = 2 -size_flags_horizontal = 3 -text = "Lock" -icon = ExtResource("1_47dd4") -expand_icon = true - -[node name="Hide Lobby Button" type="Button" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows/Tools Row"] -unique_name_in_owner = true -layout_mode = 2 -size_flags_horizontal = 3 -text = "Hide" -icon = ExtResource("1_s7umg") -expand_icon = true - -[node name="Join Lobby Button" type="Button" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows/Tools Row"] -unique_name_in_owner = true -layout_mode = 2 -size_flags_horizontal = 3 -text = "Join" -icon = ExtResource("1_avfqc") -expand_icon = true - -[node name="Delete Lobby Button" type="Button" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows/Tools Row"] -unique_name_in_owner = true -layout_mode = 2 -size_flags_horizontal = 3 -text = "Delete" -icon = ExtResource("1_c5nld") -expand_icon = true - -[node name="Leave Lobby Button" type="Button" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows/Tools Row"] -unique_name_in_owner = true -layout_mode = 2 -size_flags_horizontal = 3 -text = "Leave" -icon = SubResource("GradientTexture2D_831n5") -expand_icon = true - -[node name="Start Lobby Button" type="Button" parent="Browser UI/VBoxContainer/Tools/Tools Container/Rows/Tools Row"] -unique_name_in_owner = true -layout_mode = 2 -size_flags_horizontal = 3 -text = "Start" -icon = SubResource("GradientTexture2D_d1rvb") -expand_icon = true - -[node name="Modal" type="PopupPanel" parent="."] -unique_name_in_owner = true -initial_position = 4 -size = Vector2i(248, 168) - -[node name="PanelContainer" type="PanelContainer" parent="Modal"] -custom_minimum_size = Vector2(240, 160) -offset_left = 4.0 -offset_top = 4.0 -offset_right = 244.0 -offset_bottom = 164.0 - -[node name="MarginContainer" type="MarginContainer" parent="Modal/PanelContainer"] -layout_mode = 2 -theme_override_constants/margin_left = 4 -theme_override_constants/margin_top = 4 -theme_override_constants/margin_right = 4 -theme_override_constants/margin_bottom = 4 - -[node name="VBoxContainer" type="VBoxContainer" parent="Modal/PanelContainer/MarginContainer"] -layout_mode = 2 - -[node name="Message Label" type="Label" parent="Modal/PanelContainer/MarginContainer/VBoxContainer"] -unique_name_in_owner = true -layout_mode = 2 -size_flags_vertical = 3 -text = "Message here" - -[node name="Confirm Button" type="Button" parent="Modal/PanelContainer/MarginContainer/VBoxContainer"] -unique_name_in_owner = true -layout_mode = 2 -size_flags_horizontal = 4 -text = "OK" diff --git a/nohub/.env.example b/nohub/.env.example index 17f2475..8ad26d1 100644 --- a/nohub/.env.example +++ b/nohub/.env.example @@ -68,6 +68,4 @@ NOHUB_SESSIONS_ID_USE_NUMBER=false # Other ======================================================================= # Logging level - silent, trace, debug, info, warn, error, fatal -NOHUB_LOG_LEVEL=info -# Websocket host for web deploy option -NOHUB_WEBSOCKET_HOST=0.0.0.0 \ No newline at end of file +NOHUB_LOG_LEVEL=info \ No newline at end of file diff --git a/nohub/src/signaling/signaling.module.ts b/nohub/src/signaling/signaling.module.ts deleted file mode 100644 index b830db4..0000000 --- a/nohub/src/signaling/signaling.module.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { BroadcastModule } from "@src/broadcast/broadcast.module"; -import type { BroadcastService } from "@src/broadcast/broadcast.service"; -import type { LobbyModule } from "@src/lobbies/lobby.module"; -import type { LobbyRepository } from "@src/lobbies/lobby.repository"; -import { rootLogger } from "@src/logger"; -import type { Module } from "@src/module"; -import type { NohubReactor } from "@src/nohub"; -import { sessionOf } from "@src/sessions/session.api"; -import { requireSingleParam } from "@src/validators"; - -export class SignalingModule implements Module { - private readonly broadcastService: BroadcastService; - private readonly lobbyRepository: LobbyRepository; - private logger = rootLogger.child({ name: "SignalingApi" }); - - constructor(lobbyModule: LobbyModule, broadcastModule: BroadcastModule) { - this.broadcastService = broadcastModule.broadcastService; - this.lobbyRepository = lobbyModule.lobbyRepository; - } - - configure(reactor: NohubReactor) { - reactor - .on("webrtc/lobby/start", (cmd, xchg) => { - const lobbyId = requireSingleParam(cmd, "Missing lobby id!"); - - const session = sessionOf(xchg); - const lobby = this.lobbyRepository.requireInGame( - lobbyId, - session.gameId, - ); - - const payload = new Map() as Map; - payload.set("host", lobby.participants[0]); - payload.set("lobbyId", lobbyId); - payload.set("players", lobby.participants.join(", ")); - - // Sends to all lobby participants, they will begin WebRTC connections with each peer by sessionId. - this.broadcastService.broadcast(lobby, { - name: "webrtc/start", - kvParams: [...payload.entries()], - }); - - this.logger.info({ lobbyId }, "Starting lobby #%s", lobbyId); - - // Reply back to lobby host that we started. - xchg.reply({ text: lobby.participants.join(",") }); - }) - .on("webrtc/offer", (cmd, _xchg) => { - const sessionId = requireSingleParam(cmd, "Missing session id!"); - - this.broadcastService.unicast(sessionId, { - name: "webrtc/get/offer", - kvMap: cmd.kvMap, - }); - - // TODO: Sending an offer and getting one back could be something we could use the xchg for? - // TODO: Reply? Answer, Offer, Candidate are each "handshake" steps. - // TODO: Handle errors, for example: if STUN is not enough, indicate we need TURN. - // xchg.reply - }) - .on("webrtc/answer", (cmd, _xchg) => { - const sessionId = requireSingleParam(cmd, "Missing session id!"); - this.broadcastService.unicast(sessionId, { - name: "webrtc/get/answer", - kvMap: cmd.kvMap, - }); - }) - .on("webrtc/candidate", (cmd, _xchg) => { - const sessionId = requireSingleParam(cmd, "Missing session id!"); - this.logger.info( - { sessionId }, - "Confirming candidate for #%s", - sessionId, - ); - this.broadcastService.unicast(sessionId, { - name: "webrtc/get/candidate", - kvMap: cmd.kvMap, - }); - }); - } -} From 127fbbf344718138ddf6360bc7dac45593cc224f Mon Sep 17 00:00:00 2001 From: Andrew Davis Date: Tue, 20 Jan 2026 22:46:38 -0500 Subject: [PATCH 18/20] chore: remove signaling module --- nohub/src/nohub.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/nohub/src/nohub.ts b/nohub/src/nohub.ts index 1ea80c5..5e9fecd 100644 --- a/nohub/src/nohub.ts +++ b/nohub/src/nohub.ts @@ -11,7 +11,6 @@ import { MetricsModule } from "./metrics/metrics.module"; import type { Module } from "./module"; import type { SessionData } from "./sessions/session"; import { SessionModule } from "./sessions/session.module"; -import { SignalingModule } from "./signaling/signaling.module"; export type NohubReactor = BunSocketReactor; @@ -22,7 +21,6 @@ export class NohubModules { readonly lobbyModule: LobbyModule; readonly sessionModule: SessionModule; readonly broadcastModule: BroadcastModule; - readonly signalingModule: SignalingModule; readonly all: Module[]; @@ -42,10 +40,6 @@ export class NohubModules { this.metricsModule.metricsHolder, ); this.broadcastModule = new BroadcastModule(this.sessionModule); - this.signalingModule = new SignalingModule( - this.lobbyModule, - this.broadcastModule, - ); this.all = [ this.metricsModule, @@ -53,7 +47,6 @@ export class NohubModules { this.lobbyModule, this.sessionModule, this.broadcastModule, - this.signalingModule, ]; } } From 167139e11f547222f6decf18bc05302990716d1a Mon Sep 17 00:00:00 2001 From: Andrew Davis Date: Wed, 21 Jan 2026 11:52:17 -0500 Subject: [PATCH 19/20] fix: add a line in the close session listner to also clean up lobby sessions --- nohub/src/lobbies/lobby.repository.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nohub/src/lobbies/lobby.repository.ts b/nohub/src/lobbies/lobby.repository.ts index 019e8d8..7921ad5 100644 --- a/nohub/src/lobbies/lobby.repository.ts +++ b/nohub/src/lobbies/lobby.repository.ts @@ -35,6 +35,12 @@ export class LobbyRepository if (lobby.owner === sessionId) { this.removeItem(lobby); yield lobby; + } else { + const sessionIndex = lobby.participants.indexOf(sessionId); + if (sessionIndex !== -1) { + lobby.participants.splice(sessionIndex, 1); + this.update(lobby); + } } } From 21a073a9230a21ec589a1a219264c0917b449911 Mon Sep 17 00:00:00 2001 From: Andrew Davis Date: Wed, 21 Jan 2026 13:19:01 -0500 Subject: [PATCH 20/20] chore: add remove participants from lobby test, lint --- nohub/spec/fixtures.ts | 14 ++++++++++++ nohub/spec/lobbies/lobby.repository.spec.ts | 24 +++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/nohub/spec/fixtures.ts b/nohub/spec/fixtures.ts index d9f1ab4..7c49aca 100644 --- a/nohub/spec/fixtures.ts +++ b/nohub/spec/fixtures.ts @@ -138,6 +138,20 @@ export const Lobbies = { participants: [], }), + pamParticipantsLobby: lobbyFixture({ + id: "GzF2zDhX", + owner: Sessions.pam.id, + address: Addresses.pam, + isVisible: true, + isLocked: false, + data: new Map([ + ["name", "Pam's Participants Lobby"], + ["player-count", "8"], + ["player-capacity", "12"], + ]), + participants: [Sessions.pam.id, Sessions.ingrid.id], + }), + all(): Lobby[] { return fixturesOf(Lobbies); }, diff --git a/nohub/spec/lobbies/lobby.repository.spec.ts b/nohub/spec/lobbies/lobby.repository.spec.ts index 0a8ee51..37dd0c3 100644 --- a/nohub/spec/lobbies/lobby.repository.spec.ts +++ b/nohub/spec/lobbies/lobby.repository.spec.ts @@ -30,4 +30,28 @@ describe("LobbyRepository", () => { ); }); }); + describe("removeLobbiesOf", () => { + test("should remove lobbies owned by session", () => { + const results = [...lobbyRepository.removeLobbiesOf(Sessions.dave.id)]; + expect(results).toHaveLength(1); + expect(results[0]).toEqual(Lobbies.davesLobby); + expect(lobbyRepository.has(Lobbies.davesLobby.id)).toBeFalse(); + }); + + test("should remove session from participants if not owner", () => { + expect([...lobbyRepository.listLobbiesFor(Sessions.pam)]).toEqual([ + Lobbies.pamParticipantsLobby, + ]); + // Remove a participant who does not own a lobby. + // It does not remove the lobby, just the participant. + const results = [...lobbyRepository.removeLobbiesOf(Sessions.ingrid.id)]; + expect(results).toHaveLength(0); + expect( + lobbyRepository.require(Lobbies.pamParticipantsLobby.id).participants, + ).toContain(Sessions.pam.id); + expect( + lobbyRepository.require(Lobbies.pamParticipantsLobby.id).participants, + ).not.toContain(Sessions.ingrid.id); + }); + }); });