diff --git a/docs/api-reference.md b/docs/api-reference.md index eae50fe..2290e57 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -59,6 +59,39 @@ interface Lobby { } ``` +#### Player Counts + +##### `getPlayerCounts(filter?: object): 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`; +``` + +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. +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..1c1ce1f --- /dev/null +++ b/features/player-counts.feature @@ -0,0 +1,60 @@ +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 + 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: + | 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: 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 + + 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 dabcc99..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' @@ -490,3 +491,30 @@ 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)}`) + } +}) + +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 40b5e89..bb12f35 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, p.Country, p.Region, packet.Filter) + 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..eabf5ab 100644 --- a/internal/signaling/stores/postgres.go +++ b/internal/signaling/stores/postgres.go @@ -324,6 +324,34 @@ func (s *PostgresStore) GetLobby(ctx context.Context, game, lobbyCode string) (L return lobby, nil } +func (s *PostgresStore) GetPlayerCounts(ctx context.Context, game, country, region, filter string) (PlayerCounts, error) { + var counts PlayerCounts + 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 game_lobbies + WHERE peers.peer = ANY(game_lobbies.peers) AND (`+where+`) + )) + FROM peers + WHERE game = $1 AND disconnected = FALSE + `, append([]any{game, country, region}, values...)...).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..97eda97 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, 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 @@ -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..4271c29 100644 --- a/internal/signaling/types.go +++ b/internal/signaling/types.go @@ -44,6 +44,19 @@ type LobbiesPacket struct { Lobbies []stores.Lobby `json:"lobbies"` } +type GetPlayerCountsPacket struct { + Filter string `json:"filter"` + 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..ce8274d 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,20 @@ export default class Network extends EventEmitter { return [] } + 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', + filter: (filter != null) ? JSON.stringify(filter) : undefined + }) + 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..d52ebe4 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,13 @@ export interface EventPacket extends Base { data?: { [key: string]: string } } + +export interface GetPlayerCountsPacket extends Base { + type: 'getPlayerCounts' + filter?: string +} + +export interface PlayerCountsPacket extends Base { + type: 'playerCounts' + counts: PlayerCounts +} 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;