From 1829e6f5de726d014002cf7acd0637a446194697 Mon Sep 17 00:00:00 2001 From: Erik Dubbelboer Date: Thu, 22 Oct 2026 13:50:00 +0200 Subject: [PATCH 1/3] Add game-wide player counts Add network.getPlayerCounts() to return online and in-lobby session counts for the current game, including private lobbies. Also mark peers disconnected when they explicitly close their network. Previously they remained marked connected until timeout, even though they had already left. This would keep the eligible for leader election. --- docs/api-reference.md | 21 ++++++++++++++ features/player-counts.feature | 41 +++++++++++++++++++++++++++ features/support/steps/network.ts | 24 ++++++++++++++++ internal/signaling/peer.go | 25 ++++++++++++++++ internal/signaling/stores/postgres.go | 13 +++++++++ internal/signaling/stores/shared.go | 6 ++++ internal/signaling/types.go | 12 ++++++++ lib/network.ts | 13 ++++++++- lib/types.ts | 16 +++++++++++ 9 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 features/player-counts.feature diff --git a/docs/api-reference.md b/docs/api-reference.md index eae50fe..5e01167 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -59,6 +59,27 @@ interface Lobby { } ``` +#### Player Counts + +##### `getPlayerCounts(): Promise` +Returns a server-side snapshot of connected Netlib sessions for this instance's game ID. +Call after the `ready` event. + +```typescript +interface PlayerCounts { + online: number; // Connected sessions, including yourself + inLobby: number; // Connected sessions in public or private lobbies +} + +const { online } = await network.getPlayerCounts(); +label.textContent = `${online} players online`; +``` + +`inLobby` is a subset of `online`. Counts represent sessions, not unique people; +multiple tabs can count separately. Disconnects are reflected once detected by the +server. Players outside a lobby are not necessarily searching for a game. +The promise rejects if the network is not ready, is closing, or the request fails. + #### Communication ##### `send(channel: string, peerId: string, data: any): void` diff --git a/features/player-counts.feature b/features/player-counts.feature new file mode 100644 index 0000000..ea45ae6 --- /dev/null +++ b/features/player-counts.feature @@ -0,0 +1,41 @@ +Feature: Player Counts + + Background: + Given the "signaling" backend is running + And the "testproxy" backend is running + And "green" is connected as "1u8fw4aph5ypt" and ready for game "f666036d-d9e1-4d70-b0c3-4a68b24a9884" + + Scenario: Count yourself before joining a lobby + Then "green" should see 1 players online and 0 in a lobby + + Scenario: Count private lobby membership and leaving + When "green" creates a lobby + Then "green" should see 1 players online and 1 in a lobby + When "green" leaves the lobby + Then "green" should see 1 players online and 0 in a lobby + + Scenario: Count connected sessions only for this game + Given these peers exist: + | peer | game | disconnected | + | peer0 | f666036d-d9e1-4d70-b0c3-4a68b24a9884 | false | + | peer1 | f666036d-d9e1-4d70-b0c3-4a68b24a9884 | true | + | peer2 | 4307bd86-e1df-41b8-b9df-e22afcf084bd | false | + And these lobbies exist: + | code | game | playerCount | public | + | private | f666036d-d9e1-4d70-b0c3-4a68b24a9884 | 2 | false | + | public | f666036d-d9e1-4d70-b0c3-4a68b24a9884 | 2 | true | + | other | 4307bd86-e1df-41b8-b9df-e22afcf084bd | 3 | true | + Then "green" should see 2 players online and 1 in a lobby + + Scenario: Reject requests after closing + When "green" disconnects + And "green" tries to request player counts + Then the latest error for "green" is "network is closing or not connected" + + Scenario: Stop counting a player who closes their network + Given "blue" is connected as "h5yzwyizlwao" and ready for game "f666036d-d9e1-4d70-b0c3-4a68b24a9884" + And "green,blue" are joined in a lobby + Then "green" should see 2 players online and 2 in a lobby + When "blue" disconnects + Then "green" receives the network event "disconnected" + And "green" should see 1 players online and 1 in a lobby diff --git a/features/support/steps/network.ts b/features/support/steps/network.ts index dabcc99..4b0031e 100644 --- a/features/support/steps/network.ts +++ b/features/support/steps/network.ts @@ -490,3 +490,27 @@ Then('{string} failed to join the lobby', function (playerName: string) { throw new Error(`player is in lobby ${player.network.currentLobby as string}`) } }) + +Then('{string} should see {int} players online and {int} in a lobby', async function (this: World, playerName: string, online: number, inLobby: number) { + const player = this.players.get(playerName) + if (player == null) { + throw new Error('no such player') + } + const counts = await player.network.getPlayerCounts() + if (counts.online !== online || counts.inLobby !== inLobby) { + throw new Error(`expected ${online} online and ${inLobby} in a lobby but got ${JSON.stringify(counts)}`) + } +}) + +When('{string} tries to request player counts', async function (this: World, playerName: string) { + const player = this.players.get(playerName) + if (player == null) { + throw new Error('no such player') + } + try { + this.lastError.delete(playerName) + await player.network.getPlayerCounts() + } catch (e) { + this.lastError.set(playerName, e as Error) + } +}) diff --git a/internal/signaling/peer.go b/internal/signaling/peer.go index 40b5e89..fb7ea75 100644 --- a/internal/signaling/peer.go +++ b/internal/signaling/peer.go @@ -129,6 +129,16 @@ func (p *Peer) HandlePacket(ctx context.Context, typ string, raw []byte) error { return fmt.Errorf("unable to handle packet: %w", err) } + case "getPlayerCounts": + packet := GetPlayerCountsPacket{} + if err := json.Unmarshal(raw, &packet); err != nil { + return fmt.Errorf("unable to unmarshal json: %w", err) + } + err = p.HandleGetPlayerCountsPacket(ctx, packet) + if err != nil { + return fmt.Errorf("unable to handle packet: %w", err) + } + case "create": packet := CreatePacket{} if err := json.Unmarshal(raw, &packet); err != nil { @@ -386,6 +396,21 @@ func (p *Peer) HandleListPacket(ctx context.Context, packet ListPacket) error { }) } +func (p *Peer) HandleGetPlayerCountsPacket(ctx context.Context, packet GetPlayerCountsPacket) error { + if p.ID == "" { + return fmt.Errorf("peer not connected") + } + counts, err := p.store.GetPlayerCounts(ctx, p.Game) + if err != nil { + return err + } + return p.Send(ctx, PlayerCountsPacket{ + RequestID: packet.RequestID, + Type: "playerCounts", + Counts: counts, + }) +} + func (p *Peer) HandleCreatePacket(ctx context.Context, packet CreatePacket) error { logger := logging.GetLogger(ctx) if p.ID == "" { diff --git a/internal/signaling/stores/postgres.go b/internal/signaling/stores/postgres.go index fa0669f..1e5eaa9 100644 --- a/internal/signaling/stores/postgres.go +++ b/internal/signaling/stores/postgres.go @@ -324,6 +324,19 @@ func (s *PostgresStore) GetLobby(ctx context.Context, game, lobbyCode string) (L return lobby, nil } +func (s *PostgresStore) GetPlayerCounts(ctx context.Context, game string) (PlayerCounts, error) { + var counts PlayerCounts + err := s.DB.QueryRow(ctx, ` + SELECT COUNT(*), COUNT(*) FILTER (WHERE EXISTS ( + SELECT 1 FROM lobbies + WHERE lobbies.game = peers.game AND peers.peer = ANY(lobbies.peers) + )) + FROM peers + WHERE game = $1 AND disconnected = FALSE + `, game).Scan(&counts.Online, &counts.InLobby) + return counts, err +} + func (s *PostgresStore) ListLobbies(ctx context.Context, game string, country, region string, filter, sort string, limit int) ([]Lobby, error) { // TODO: Remove this. if filter == "" { diff --git a/internal/signaling/stores/shared.go b/internal/signaling/stores/shared.go index 33c752f..bfd73f9 100644 --- a/internal/signaling/stores/shared.go +++ b/internal/signaling/stores/shared.go @@ -25,6 +25,7 @@ type LobbyOptions struct { } type Store interface { + GetPlayerCounts(ctx context.Context, game string) (PlayerCounts, error) CreateLobby(ctx context.Context, Game, LobbyCode, PeerID string, options LobbyOptions) error JoinLobby(ctx context.Context, game, lobby, id, password string) ([]string, error) LeaveLobby(ctx context.Context, game, lobby, id string) error @@ -88,3 +89,8 @@ type TimedOutPeer struct { ID string GameLobbies map[string][]string } + +type PlayerCounts struct { + Online int `json:"online"` + InLobby int `json:"inLobby"` +} diff --git a/internal/signaling/types.go b/internal/signaling/types.go index 1fbcb45..504aed1 100644 --- a/internal/signaling/types.go +++ b/internal/signaling/types.go @@ -44,6 +44,18 @@ type LobbiesPacket struct { Lobbies []stores.Lobby `json:"lobbies"` } +type GetPlayerCountsPacket struct { + RequestID string `json:"rid"` + Type string `json:"type"` +} + +type PlayerCountsPacket struct { + RequestID string `json:"rid"` + Type string `json:"type"` + + Counts stores.PlayerCounts `json:"counts"` +} + type CreatePacket struct { RequestID string `json:"rid"` Type string `json:"type"` diff --git a/lib/network.ts b/lib/network.ts index 25feb08..d632baa 100644 --- a/lib/network.ts +++ b/lib/network.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'eventemitter3' import { DefaultDataChannels, DefaultRTCConfiguration, DefaultSignalingURL } from '.' -import { LobbyListEntry, LobbySettings, PeerConfiguration } from './types' +import { LobbyListEntry, LobbySettings, PeerConfiguration, PlayerCounts } from './types' import Signaling, { SignalingError } from './signaling' import Peer from './peer' import Credentials from './credentials' @@ -66,6 +66,17 @@ export default class Network extends EventEmitter { return [] } + async getPlayerCounts (): Promise { + if (this._closing || this.signaling.receivedID === undefined) { + throw new Error('network is closing or not connected') + } + const reply = await this.signaling.request({ type: 'getPlayerCounts' }) + if (reply.type === 'playerCounts') { + return reply.counts + } + throw new Error('unexpected player counts response') + } + async create (settings?: LobbySettings): Promise { if (this._closing || this.signaling.receivedID === undefined) { return '' diff --git a/lib/types.ts b/lib/types.ts index 97c899f..23baf32 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -30,6 +30,11 @@ export interface LobbyListEntry { latency?: number } +export interface PlayerCounts { + online: number + inLobby: number +} + interface Base { type: string rid?: string @@ -57,6 +62,8 @@ export type SignalingPacketTypes = | LeftPacket | ListPacket | LobbiesPacket +| GetPlayerCountsPacket +| PlayerCountsPacket | PingPacket | WelcomePacket @@ -202,3 +209,12 @@ export interface EventPacket extends Base { data?: { [key: string]: string } } + +export interface GetPlayerCountsPacket extends Base { + type: 'getPlayerCounts' +} + +export interface PlayerCountsPacket extends Base { + type: 'playerCounts' + counts: PlayerCounts +} From b4e7faf6d37e6e2fa250ab5a6fd87b90ed84438c Mon Sep 17 00:00:00 2001 From: Erik Dubbelboer Date: Sat, 24 Oct 2026 06:40:37 +0200 Subject: [PATCH 2/3] Fixes --- features/player-counts.feature | 9 ++------- features/support/steps/network.ts | 13 ------------- migrations/1792815631_connected_peers_game.down.sql | 5 +++++ migrations/1792815631_connected_peers_game.up.sql | 6 ++++++ 4 files changed, 13 insertions(+), 20 deletions(-) create mode 100644 migrations/1792815631_connected_peers_game.down.sql create mode 100644 migrations/1792815631_connected_peers_game.up.sql diff --git a/features/player-counts.feature b/features/player-counts.feature index ea45ae6..9492e5b 100644 --- a/features/player-counts.feature +++ b/features/player-counts.feature @@ -11,8 +11,8 @@ Feature: Player Counts Scenario: Count private lobby membership and leaving When "green" creates a lobby Then "green" should see 1 players online and 1 in a lobby - When "green" leaves the lobby - Then "green" should see 1 players online and 0 in a lobby + And "green" leaves the lobby + And "green" should see 1 players online and 0 in a lobby Scenario: Count connected sessions only for this game Given these peers exist: @@ -27,11 +27,6 @@ Feature: Player Counts | other | 4307bd86-e1df-41b8-b9df-e22afcf084bd | 3 | true | Then "green" should see 2 players online and 1 in a lobby - Scenario: Reject requests after closing - When "green" disconnects - And "green" tries to request player counts - Then the latest error for "green" is "network is closing or not connected" - Scenario: Stop counting a player who closes their network Given "blue" is connected as "h5yzwyizlwao" and ready for game "f666036d-d9e1-4d70-b0c3-4a68b24a9884" And "green,blue" are joined in a lobby diff --git a/features/support/steps/network.ts b/features/support/steps/network.ts index 4b0031e..2032128 100644 --- a/features/support/steps/network.ts +++ b/features/support/steps/network.ts @@ -501,16 +501,3 @@ Then('{string} should see {int} players online and {int} in a lobby', async func throw new Error(`expected ${online} online and ${inLobby} in a lobby but got ${JSON.stringify(counts)}`) } }) - -When('{string} tries to request player counts', async function (this: World, playerName: string) { - const player = this.players.get(playerName) - if (player == null) { - throw new Error('no such player') - } - try { - this.lastError.delete(playerName) - await player.network.getPlayerCounts() - } catch (e) { - this.lastError.set(playerName, e as Error) - } -}) diff --git a/migrations/1792815631_connected_peers_game.down.sql b/migrations/1792815631_connected_peers_game.down.sql new file mode 100644 index 0000000..d67f13c --- /dev/null +++ b/migrations/1792815631_connected_peers_game.down.sql @@ -0,0 +1,5 @@ +BEGIN; + +DROP INDEX "peers_connected_game"; + +COMMIT; diff --git a/migrations/1792815631_connected_peers_game.up.sql b/migrations/1792815631_connected_peers_game.up.sql new file mode 100644 index 0000000..b3fbae1 --- /dev/null +++ b/migrations/1792815631_connected_peers_game.up.sql @@ -0,0 +1,6 @@ +BEGIN; + +CREATE INDEX "peers_connected_game" ON "peers" ("game") INCLUDE ("peer") + WHERE "disconnected" = FALSE; + +COMMIT; From 5dc68ed7597b0ce474e0824c75529fe25bfbb2bc Mon Sep 17 00:00:00 2001 From: Erik Dubbelboer Date: Mon, 26 Oct 2026 05:47:40 +0100 Subject: [PATCH 3/3] Allow passing a filter to getPlayerCounts() --- docs/api-reference.md | 14 +++++++++++++- features/player-counts.feature | 24 ++++++++++++++++++++++++ features/support/steps/network.ts | 17 +++++++++++++++++ internal/signaling/peer.go | 2 +- internal/signaling/stores/postgres.go | 25 ++++++++++++++++++++----- internal/signaling/stores/shared.go | 2 +- internal/signaling/types.go | 1 + lib/network.ts | 7 +++++-- lib/types.ts | 1 + 9 files changed, 83 insertions(+), 10 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 5e01167..2290e57 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -61,7 +61,7 @@ interface Lobby { #### Player Counts -##### `getPlayerCounts(): Promise` +##### `getPlayerCounts(filter?: object): Promise` Returns a server-side snapshot of connected Netlib sessions for this instance's game ID. Call after the `ready` event. @@ -75,6 +75,18 @@ const { online } = await network.getPlayerCounts(); label.textContent = `${online} players online`; ``` +The optional filter uses the same MongoDB-style syntax as `list()`, including +lobby custom data fields. It applies only to `inLobby`; `online` always counts all +connected sessions for the game. Matching private lobbies are included, and each +connected session is counted at most once. + +```typescript +const { inLobby } = await network.getPlayerCounts({ kind: 'ranked' }); +``` + +Omitting the filter or passing `{}` counts membership in any lobby. Invalid filters +reject the promise. + `inLobby` is a subset of `online`. Counts represent sessions, not unique people; multiple tabs can count separately. Disconnects are reflected once detected by the server. Players outside a lobby are not necessarily searching for a game. diff --git a/features/player-counts.feature b/features/player-counts.feature index 9492e5b..1c1ce1f 100644 --- a/features/player-counts.feature +++ b/features/player-counts.feature @@ -34,3 +34,27 @@ Feature: Player Counts When "blue" disconnects Then "green" receives the network event "disconnected" And "green" should see 1 players online and 1 in a lobby + + Scenario: Filter lobby membership while keeping online counts game-wide + Given these peers exist: + | peer | game | disconnected | + | peer0 | f666036d-d9e1-4d70-b0c3-4a68b24a9884 | false | + | peer1 | f666036d-d9e1-4d70-b0c3-4a68b24a9884 | true | + | peer2 | f666036d-d9e1-4d70-b0c3-4a68b24a9884 | false | + | peer3 | 4307bd86-e1df-41b8-b9df-e22afcf084bd | false | + And these lobbies exist: + | code | game | playerCount | public | custom_data | + | private | f666036d-d9e1-4d70-b0c3-4a68b24a9884 | 2 | false | {"kind":"ranked"} | + | ranked | f666036d-d9e1-4d70-b0c3-4a68b24a9884 | 2 | true | {"kind":"ranked"} | + | casual | f666036d-d9e1-4d70-b0c3-4a68b24a9884 | 3 | true | {"kind":"casual"} | + | other | 4307bd86-e1df-41b8-b9df-e22afcf084bd | 4 | true | {"kind":"other"} | + Then "green" should see 3 players online and 2 in a lobby + And "green" should see 3 players online and 2 in a lobby matching '{}' + And "green" should see 3 players online and 1 in a lobby matching '{"kind":"ranked"}' + And "green" should see 3 players online and 2 in a lobby matching '{"kind":"casual"}' + And "green" should see 3 players online and 0 in a lobby matching '{"kind":"other"}' + And "green" should see 3 players online and 1 in a lobby matching '{"code":"private"}' + And "green" should see 3 players online and 1 in a lobby matching '{"kind":{"$in":["ranked"]},"playerCount":{"$gte":2}}' + + Scenario: Reject invalid lobby filters + Then "green" should reject player counts matching '{"kind":{"$invalid":"ranked"}}' diff --git a/features/support/steps/network.ts b/features/support/steps/network.ts index 2032128..15c37ed 100644 --- a/features/support/steps/network.ts +++ b/features/support/steps/network.ts @@ -1,3 +1,4 @@ +import { strict as assert } from 'assert' import { After, DataTable, Given, Then, When } from '@cucumber/cucumber' import { World } from '../world' @@ -501,3 +502,19 @@ Then('{string} should see {int} players online and {int} in a lobby', async func throw new Error(`expected ${online} online and ${inLobby} in a lobby but got ${JSON.stringify(counts)}`) } }) + +Then('{string} should see {int} players online and {int} in a lobby matching {string}', async function (this: World, playerName: string, online: number, inLobby: number, filter: string) { + const player = this.players.get(playerName) + if (player == null) { + throw new Error('no such player') + } + assert.deepEqual(await player.network.getPlayerCounts(JSON.parse(filter)), { online, inLobby }) +}) + +Then('{string} should reject player counts matching {string}', async function (this: World, playerName: string, filter: string) { + const player = this.players.get(playerName) + if (player == null) { + throw new Error('no such player') + } + await assert.rejects(player.network.getPlayerCounts(JSON.parse(filter)), /invalid filter/) +}) diff --git a/internal/signaling/peer.go b/internal/signaling/peer.go index fb7ea75..bb12f35 100644 --- a/internal/signaling/peer.go +++ b/internal/signaling/peer.go @@ -400,7 +400,7 @@ func (p *Peer) HandleGetPlayerCountsPacket(ctx context.Context, packet GetPlayer if p.ID == "" { return fmt.Errorf("peer not connected") } - counts, err := p.store.GetPlayerCounts(ctx, p.Game) + counts, err := p.store.GetPlayerCounts(ctx, p.Game, p.Country, p.Region, packet.Filter) if err != nil { return err } diff --git a/internal/signaling/stores/postgres.go b/internal/signaling/stores/postgres.go index 1e5eaa9..eabf5ab 100644 --- a/internal/signaling/stores/postgres.go +++ b/internal/signaling/stores/postgres.go @@ -324,16 +324,31 @@ func (s *PostgresStore) GetLobby(ctx context.Context, game, lobbyCode string) (L return lobby, nil } -func (s *PostgresStore) GetPlayerCounts(ctx context.Context, game string) (PlayerCounts, error) { +func (s *PostgresStore) GetPlayerCounts(ctx context.Context, game, country, region, filter string) (PlayerCounts, error) { var counts PlayerCounts - err := s.DB.QueryRow(ctx, ` + if filter == "" { + filter = "{}" + } + where, values, err := s.filterConverter.Convert([]byte(filter), 4) + if err != nil { + return counts, fmt.Errorf("invalid filter: %w", err) + } + err = s.DB.QueryRow(ctx, ` + WITH game_lobbies AS ( + SELECT code, peers, custom_data, + COALESCE(ARRAY_LENGTH(peers, 1), 0) AS "playerCount", + created_at AS "createdAt", updated_at AS "updatedAt", + lobby_latency_estimate(peers, $2, $3) AS latency + FROM lobbies + WHERE game = $1 + ) SELECT COUNT(*), COUNT(*) FILTER (WHERE EXISTS ( - SELECT 1 FROM lobbies - WHERE lobbies.game = peers.game AND peers.peer = ANY(lobbies.peers) + SELECT 1 FROM game_lobbies + WHERE peers.peer = ANY(game_lobbies.peers) AND (`+where+`) )) FROM peers WHERE game = $1 AND disconnected = FALSE - `, game).Scan(&counts.Online, &counts.InLobby) + `, append([]any{game, country, region}, values...)...).Scan(&counts.Online, &counts.InLobby) return counts, err } diff --git a/internal/signaling/stores/shared.go b/internal/signaling/stores/shared.go index bfd73f9..97eda97 100644 --- a/internal/signaling/stores/shared.go +++ b/internal/signaling/stores/shared.go @@ -25,7 +25,7 @@ type LobbyOptions struct { } type Store interface { - GetPlayerCounts(ctx context.Context, game string) (PlayerCounts, error) + GetPlayerCounts(ctx context.Context, game, country, region, filter string) (PlayerCounts, error) CreateLobby(ctx context.Context, Game, LobbyCode, PeerID string, options LobbyOptions) error JoinLobby(ctx context.Context, game, lobby, id, password string) ([]string, error) LeaveLobby(ctx context.Context, game, lobby, id string) error diff --git a/internal/signaling/types.go b/internal/signaling/types.go index 504aed1..4271c29 100644 --- a/internal/signaling/types.go +++ b/internal/signaling/types.go @@ -45,6 +45,7 @@ type LobbiesPacket struct { } type GetPlayerCountsPacket struct { + Filter string `json:"filter"` RequestID string `json:"rid"` Type string `json:"type"` } diff --git a/lib/network.ts b/lib/network.ts index d632baa..ce8274d 100644 --- a/lib/network.ts +++ b/lib/network.ts @@ -66,11 +66,14 @@ export default class Network extends EventEmitter { return [] } - async getPlayerCounts (): Promise { + async getPlayerCounts (filter?: object): Promise { if (this._closing || this.signaling.receivedID === undefined) { throw new Error('network is closing or not connected') } - const reply = await this.signaling.request({ type: 'getPlayerCounts' }) + const reply = await this.signaling.request({ + type: 'getPlayerCounts', + filter: (filter != null) ? JSON.stringify(filter) : undefined + }) if (reply.type === 'playerCounts') { return reply.counts } diff --git a/lib/types.ts b/lib/types.ts index 23baf32..d52ebe4 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -212,6 +212,7 @@ export interface EventPacket extends Base { export interface GetPlayerCountsPacket extends Base { type: 'getPlayerCounts' + filter?: string } export interface PlayerCountsPacket extends Base {