diff --git a/nohub/.env.example b/nohub/.env.example index 641b122..8ad26d1 100644 --- a/nohub/.env.example +++ b/nohub/.env.example @@ -63,7 +63,9 @@ 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 +NOHUB_LOG_LEVEL=info \ No newline at end of file diff --git a/nohub/spec/api/sessions.api.spec.ts b/nohub/spec/api/sessions.api.spec.ts index 3ac66db..4647803 100644 --- a/nohub/spec/api/sessions.api.spec.ts +++ b/nohub/spec/api/sessions.api.spec.ts @@ -23,4 +23,20 @@ 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/broadcast/broadcast.service.spec.ts b/nohub/spec/broadcast/broadcast.service.spec.ts new file mode 100644 index 0000000..849e770 --- /dev/null +++ b/nohub/spec/broadcast/broadcast.service.spec.ts @@ -0,0 +1,125 @@ +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 { readDefaultConfig } from "@src/config"; +import { DataNotFoundError } from "@src/errors"; +import { NohubEventBus } from "@src/events"; +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 { SessionApi } from "@src/sessions/session.api"; +import { SessionRepository } from "@src/sessions/session.repository"; +import type { Socket } from "bun"; + +let reactor: NohubReactor; + +let sessionRepository: SessionRepository; +let sessionApi: SessionApi; +let lobbyRepository: LobbyRepository; +let lobbyService: LobbyService; +let lobbyApi: LobbyApi; + +let broadcastService: BroadcastService; + +let daveSocket: Socket; +let ericSocket: Socket; + +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 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", + }); + }); + + test("should skip missing sessions", () => { + const lobby = lobbyApi.create(Sessions.dave.address, daveSocket.data); + + // Join + lobbyApi.join(lobby.id, ericSocket.data); + // Close (leave) + sessionApi.closeSession(ericSocket); + + 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/fixtures.ts b/nohub/spec/fixtures.ts index 440b034..7c49aca 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,21 @@ export const Lobbies = { ["player-count", "4"], ["player-capacity", "6"], ]), + 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[] { 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); + }); + }); }); diff --git a/nohub/spec/lobbies/lobby.service.spec.ts b/nohub/spec/lobbies/lobby.service.spec.ts index 1890edc..a5f510a 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( @@ -212,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/spec/sessions/session.api.spec.ts b/nohub/spec/sessions/session.api.spec.ts index 2899b5f..13809d6 100644 --- a/nohub/spec/sessions/session.api.spec.ts +++ b/nohub/spec/sessions/session.api.spec.ts @@ -104,6 +104,18 @@ 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", () => { @@ -120,7 +132,7 @@ describe("SessionApi", () => { }); }); -function mockSocket(address: string): Socket { +export function mockSocket(address: string): Socket { return { remoteAddress: address, write: ( diff --git a/nohub/src/broadcast/broadcast.module.ts b/nohub/src/broadcast/broadcast.module.ts new file mode 100644 index 0000000..15b0cee --- /dev/null +++ b/nohub/src/broadcast/broadcast.module.ts @@ -0,0 +1,25 @@ +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.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 new file mode 100644 index 0000000..8567a8a --- /dev/null +++ b/nohub/src/broadcast/broadcast.service.ts @@ -0,0 +1,37 @@ +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 { 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(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); + } + + 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(sessionId, command)); + } + + return result; + } +} 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/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.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); + } } } diff --git a/nohub/src/lobbies/lobby.service.ts b/nohub/src/lobbies/lobby.service.ts index ea1f384..f1e8c27 100644 --- a/nohub/src/lobbies/lobby.service.ts +++ b/nohub/src/lobbies/lobby.service.ts @@ -65,6 +65,7 @@ export class LobbyService { isVisible: true, isLocked: false, data, + participants: [session.id], }; this.repository.add(lobby); @@ -87,9 +88,25 @@ export class LobbyService { join(lobby: Lobby, session: SessionData): string { requireLobbyJoinable(lobby, session); + lobby.participants.push(session.id); + this.repository.update(lobby); + 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, 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..5e9fecd 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"; @@ -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, ]; } } @@ -147,6 +151,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..afd5cba 100644 --- a/nohub/src/sessions/session.api.ts +++ b/nohub/src/sessions/session.api.ts @@ -7,7 +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 { nanoid } from "nanoid"; +import { customAlphabet, nanoid } from "nanoid"; import type { SessionData } from "./session"; import type { SessionRepository } from "./session.repository"; @@ -24,6 +24,10 @@ export class SessionApi { ) {} generateSessionId(): string { + if (this.config.idUseNumber) { + const nanoidNumber = customAlphabet("1234567890"); + return nanoidNumber(this.config.idLength); + } return nanoid(this.config.idLength); } @@ -55,6 +59,7 @@ export class SessionApi { id: this.generateSessionId(), gameId: this.config.defaultGameId, address, + socket, }; this.sessionRepository.add(session); 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/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; }