Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,39 @@ interface Lobby {
}
```

#### Player Counts

##### `getPlayerCounts(filter?: object): Promise<PlayerCounts>`
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`
Expand Down
60 changes: 60 additions & 0 deletions features/player-counts.feature
Original file line number Diff line number Diff line change
@@ -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"}}'
28 changes: 28 additions & 0 deletions features/support/steps/network.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { strict as assert } from 'assert'
import { After, DataTable, Given, Then, When } from '@cucumber/cucumber'
import { World } from '../world'

Expand Down Expand Up @@ -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/)
})
25 changes: 25 additions & 0 deletions internal/signaling/peer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 == "" {
Expand Down
28 changes: 28 additions & 0 deletions internal/signaling/stores/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
erikdubbelboer marked this conversation as resolved.
Resolved

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a pretty heavy query for a player count. Doesn't the WITH game_lobbies keep all lobbies in memory?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not MATERIALIZED so it will just be inlined instead of running that query first and then using the result for the outer query.

`, 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 == "" {
Expand Down
6 changes: 6 additions & 0 deletions internal/signaling/stores/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -88,3 +89,8 @@ type TimedOutPeer struct {
ID string
GameLobbies map[string][]string
}

type PlayerCounts struct {
Online int `json:"online"`
InLobby int `json:"inLobby"`
}
13 changes: 13 additions & 0 deletions internal/signaling/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
16 changes: 15 additions & 1 deletion lib/network.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -66,6 +66,20 @@ export default class Network extends EventEmitter<NetworkListeners> {
return []
}

async getPlayerCounts (filter?: object): Promise<PlayerCounts> {
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<string> {
if (this._closing || this.signaling.receivedID === undefined) {
return ''
Expand Down
17 changes: 17 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export interface LobbyListEntry {
latency?: number
}

export interface PlayerCounts {
online: number
inLobby: number
}

interface Base {
type: string
rid?: string
Expand Down Expand Up @@ -57,6 +62,8 @@ export type SignalingPacketTypes =
| LeftPacket
| ListPacket
| LobbiesPacket
| GetPlayerCountsPacket
| PlayerCountsPacket
| PingPacket
| WelcomePacket

Expand Down Expand Up @@ -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
}
5 changes: 5 additions & 0 deletions migrations/1792815631_connected_peers_game.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
BEGIN;

DROP INDEX "peers_connected_game";

COMMIT;
6 changes: 6 additions & 0 deletions migrations/1792815631_connected_peers_game.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
BEGIN;

CREATE INDEX "peers_connected_game" ON "peers" ("game") INCLUDE ("peer")
WHERE "disconnected" = FALSE;

COMMIT;
Loading