From fd7793ffde28a0224405e65ff0ee5d2576d9ca98 Mon Sep 17 00:00:00 2001 From: Erik Dubbelboer Date: Thu, 22 Oct 2026 15:34:04 +0200 Subject: [PATCH 1/3] Add atomic quick-join for public lobbies Add `network.quickJoin()` to join the oldest available public lobby or create one. Serialize this per game so simultaneous requests don't create separate lobbies. Reuse lobby creation settings, excluding visibility and passwords. Apply these settings only when creating a lobby. --- README.md | 11 ++++ docs/api-reference.md | 13 +++++ features/quick-join.feature | 59 +++++++++++++++++++ features/support/steps/network.ts | 22 ++++++++ internal/signaling/peer.go | 81 +++++++++++++++++++++------ internal/signaling/stores/postgres.go | 79 ++++++++++++++++++++++++++ internal/signaling/stores/shared.go | 1 + internal/signaling/types.go | 10 ++++ internal/util/identifiers.go | 12 ++++ lib/network.ts | 16 +++++- lib/types.ts | 7 +++ 11 files changed, 293 insertions(+), 18 deletions(-) create mode 100644 features/quick-join.feature diff --git a/README.md b/README.md index b9604ce..789e0b3 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,17 @@ const network = new Network('') ``` 3. Create or join a lobby: + +For automatic matchmaking, quick-join the oldest available public lobby or create one atomically: + +```javascript +network.on('ready', async () => { + const lobby = await network.quickJoin({ maxPlayers: 4 }) + console.log('Joined lobby:', lobby?.code) +}) +``` + +Alternatively, create or join a specific lobby: ```js // Create a new lobby network.on('ready', () => { diff --git a/docs/api-reference.md b/docs/api-reference.md index 2290e57..4b0c80d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -38,6 +38,19 @@ interface LobbyOptions { ##### `join(code: string, password?: string): Promise` Joins an existing lobby. +##### `quickJoin(settings?: QuickJoinSettings): Promise` +Joins the oldest public, password-free, nonempty lobby with available capacity for this game, or creates one atomically. Simultaneous quick-join requests share lobbies without exceeding their capacity. Lobbies with the same creation time are ordered by code. + +```typescript +type QuickJoinSettings = Omit; + +const lobby = await network.quickJoin({ maxPlayers: 4 }); +``` + +`QuickJoinSettings` reuses the settings accepted by `create()`, except `public` and `password`. Settings apply only when creating a lobby; they do not filter or modify existing lobbies. This includes `codeFormat`, `maxPlayers`, `customData`, and `canUpdateBy`. New lobbies are always public and password-free. Defaults match `create()`, including four players and creator-only update permissions. Empty lobbies are excluded. The method returns `undefined` when the network is closing or not ready, matching `join()`. + +Use this instead of separate `list()`, `join()`, and `create()` calls for automatic matchmaking. Existing lobby and connection events work as usual. Callers using `create()` can still create separate lobbies. Deploy signaling server support before using this SDK method; older servers return an error and there is no client-side fallback. + ##### `leave(): Promise` Leaves the current lobby without closing the network. diff --git a/features/quick-join.feature b/features/quick-join.feature new file mode 100644 index 0000000..decfa74 --- /dev/null +++ b/features/quick-join.feature @@ -0,0 +1,59 @@ +Feature: Players can atomically find or create a public lobby + + Background: + Given the "signaling" backend is running + + Scenario: Simultaneous quick-joins connect players in the same lobby + Given "blue" is connected as "1u8fw4aph5ypt" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + And "yellow" is connected as "h5yzwyizlwao" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + + When "blue,yellow" quick-join simultaneously with a maximum of 2 players + Then "blue" receives the network event "lobby" with the argument "19yrzmetd2bn7" + And "yellow" receives the network event "lobby" with the argument "19yrzmetd2bn7" + And "blue" receives the network event "connected" with the argument "[Peer: h5yzwyizlwao]" + And "yellow" receives the network event "connected" with the argument "[Peer: 1u8fw4aph5ypt]" + + When "blue" boardcasts "Hello, world!" over the reliable channel + Then "yellow" receives the network event "message" with the arguments "[Peer: 1u8fw4aph5ypt]", "reliable" and "Hello, world!" + + When "blue" requests lobbies with: + """json + {} + """ + Then "blue" should have received only these lobbies: + | code | playerCount | maxPlayers | public | hasPassword | + | 19yrzmetd2bn7 | 2 | 2 | true | false | + + Scenario: Creation settings apply only when quick-join creates a lobby + Given "blue" is connected as "1u8fw4aph5ypt" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + When "blue" quick-joins with these settings: + """json + { + "codeFormat": "short", + "maxPlayers": 3, + "customData": { "mode": "teams" }, + "canUpdateBy": "none" + } + """ + Then "blue" receives the network event "lobby" with the argument "SHC6" + + When "yellow" creates a network for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + Then "yellow" receives the network event "ready" + When "yellow" quick-joins with these settings: + """json + { + "codeFormat": "default", + "maxPlayers": 1, + "customData": { "mode": "solo" }, + "canUpdateBy": "anyone" + } + """ + Then "yellow" receives the network event "lobby" with the argument "SHC6" + + When "blue" requests lobbies with: + """json + {} + """ + Then "blue" should have received only these lobbies: + | code | playerCount | maxPlayers | public | hasPassword | customData | canUpdateBy | + | SHC6 | 2 | 3 | true | false | {"mode":"teams"} | none | diff --git a/features/support/steps/network.ts b/features/support/steps/network.ts index 15c37ed..f47aefa 100644 --- a/features/support/steps/network.ts +++ b/features/support/steps/network.ts @@ -194,6 +194,28 @@ When('{string} creates a lobby with these settings:', async function (this: Worl await player.network.create(settings) }) +When('{string} quick-joins with these settings:', async function (this: World, playerName: string, settingsBlob: string) { + const player = this.players.get(playerName) + if (player == null) { + throw new Error('no such player') + } + const settings = JSON.parse(settingsBlob) + await player.network.quickJoin(settings) +}) + +When('{string} quick-join simultaneously with a maximum of {int} players', async function (this: World, playerNamesRaw: string, maxPlayers: number) { + await Promise.all(playerNamesRaw.split(',').map(async playerName => { + const player = this.players.get(playerName.trim()) + if (player == null) { + throw new Error('no such player') + } + const lobby = await player.network.quickJoin({ maxPlayers }) + if (lobby === undefined || lobby.code !== player.network.currentLobby) { + throw new Error('quick-join did not return the current lobby') + } + })) +}) + When('{string} connects to the lobby {string}', async function (this: World, playerName: string, lobbyCode: string) { const player = this.players.get(playerName) if (player == null) { diff --git a/internal/signaling/peer.go b/internal/signaling/peer.go index bb12f35..a3c296b 100644 --- a/internal/signaling/peer.go +++ b/internal/signaling/peer.go @@ -159,6 +159,16 @@ func (p *Peer) HandlePacket(ctx context.Context, typ string, raw []byte) error { return fmt.Errorf("unable to handle packet: %w", err) } + case "quickJoin": + packet := QuickJoinPacket{} + if err := json.Unmarshal(raw, &packet); err != nil { + return fmt.Errorf("unable to unmarshal json: %w", err) + } + err = p.HandleQuickJoinPacket(ctx, packet) + if err != nil { + return fmt.Errorf("unable to handle packet: %w", err) + } + case "lobbyUpdate": packet := LobbyUpdatePacket{} if err := json.Unmarshal(raw, &packet); err != nil { @@ -438,17 +448,7 @@ func (p *Peer) HandleCreatePacket(ctx context.Context, packet CreatePacket) erro attempts := 0 for ; attempts < 20; attempts++ { - if packet.CodeFormat == "short" && attempts < 19 { - // The first 15 attempts will try to generate a 4 character code. - // After that, it will try to generate a 5 character code. - length := 4 - if attempts > 15 { - length = 5 - } - p.Lobby = util.GenerateShortLobbyCode(ctx, length) - } else { - p.Lobby = util.GenerateLobbyCode(ctx) - } + p.Lobby = util.GenerateLobbyCodeWithFormat(ctx, packet.CodeFormat, attempts) err := p.store.CreateLobby(ctx, p.Game, p.Lobby, p.ID, stores.LobbyOptions{ Public: &packet.Public, @@ -491,7 +491,6 @@ func (p *Peer) HandleCreatePacket(ctx context.Context, packet CreatePacket) erro } func (p *Peer) HandleJoinPacket(ctx context.Context, packet JoinPacket) error { - logger := logging.GetLogger(ctx) if p.ID == "" { return fmt.Errorf("peer not connected") } @@ -522,11 +521,58 @@ func (p *Peer) HandleJoinPacket(ctx context.Context, packet JoinPacket) error { return err } - p.Lobby = packet.Lobby + return p.finishJoin(ctx, packet.RequestID, packet.Lobby, existingPeers, "joined") +} + +func (p *Peer) HandleQuickJoinPacket(ctx context.Context, packet QuickJoinPacket) error { + if p.ID == "" { + return fmt.Errorf("peer not connected") + } + if p.Lobby != "" { + return fmt.Errorf("already in a lobby %s:%s as %s", p.Game, p.Lobby, p.ID) + } + + if packet.CanUpdateBy == "" { + packet.CanUpdateBy = stores.CanUpdateByCreator + } else if packet.CanUpdateBy != "" { + if packet.CanUpdateBy != stores.CanUpdateByCreator && + packet.CanUpdateBy != stores.CanUpdateByLeader && + packet.CanUpdateBy != stores.CanUpdateByAnyone && + packet.CanUpdateBy != stores.CanUpdateByNone { + return fmt.Errorf("invalid canUpdateBy value") + } + } + + maxPlayers := DefaultMaxPlayers + if packet.MaxPlayers != nil { + maxPlayers = *packet.MaxPlayers + } + if maxPlayers < 0 { + return fmt.Errorf("invalid maxPlayers value") + } + + lobbyCode, existingPeers, created, err := p.store.QuickJoinLobby(ctx, p.Game, p.ID, packet.CodeFormat, stores.LobbyOptions{ + CustomData: &packet.CustomData, + CanUpdateBy: &packet.CanUpdateBy, + MaxPlayers: &maxPlayers, + }) + if err != nil { + return err + } + + action := "joined" + if created { + action = "created" + } + return p.finishJoin(ctx, packet.RequestID, lobbyCode, existingPeers, action) +} + +func (p *Peer) finishJoin(ctx context.Context, requestID, lobbyCode string, existingPeers []string, action string) error { + p.Lobby = lobbyCode p.store.Subscribe(ctx, p.ForwardMessage, p.Game, p.Lobby, p.ID) // Lobby might be empty when joining, then you need to become the leader. - _, err = p.doLeaderElectionAndPublish(ctx) + _, err := p.doLeaderElectionAndPublish(ctx) if err != nil { return err } @@ -537,7 +583,7 @@ func (p *Peer) HandleJoinPacket(ctx context.Context, packet JoinPacket) error { } err = p.Send(ctx, JoinedPacket{ - RequestID: packet.RequestID, + RequestID: requestID, Type: "joined", LobbyCode: p.Lobby, // backwards compatibility LobbyInfo: lobby, @@ -557,12 +603,13 @@ func (p *Peer) HandleJoinPacket(ctx context.Context, packet JoinPacket) error { } } - logger.Debug("joined lobby", + logger := logging.GetLogger(ctx) + logger.Debug(action+" lobby", zap.String("game", p.Game), zap.String("lobby", p.Lobby), zap.String("peer", p.ID), zap.Strings("peers", lobby.Peers)) - go metrics.Record(ctx, "lobby", "joined", p.Game, p.ID, p.Lobby) + go metrics.Record(ctx, "lobby", action, p.Game, p.ID, p.Lobby) return nil } diff --git a/internal/signaling/stores/postgres.go b/internal/signaling/stores/postgres.go index eabf5ab..321d707 100644 --- a/internal/signaling/stores/postgres.go +++ b/internal/signaling/stores/postgres.go @@ -276,6 +276,85 @@ func (s *PostgresStore) JoinLobby(ctx context.Context, game, lobbyCode, peerID, return peerlist, nil } +func (s *PostgresStore) QuickJoinLobby(ctx context.Context, game, peerID, codeFormat string, options LobbyOptions) (string, []string, bool, error) { + if len(peerID) > 20 { + return "", nil, false, ErrInvalidPeerID + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return "", nil, false, err + } + defer tx.Rollback(context.Background()) //nolint:errcheck + + // Lock the game before looking for a lobby, including when none exists yet. + // Use the canonical UUID so different spellings of the same game share a lock. + _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended('quickJoin:' || $1::uuid::text, 0))`, game) + if err != nil { + return "", nil, false, err + } + + var lobbyCode string + var peerlist []string + err = tx.QueryRow(ctx, ` + SELECT code, peers + FROM lobbies + WHERE game = $1 + AND public = true + AND password IS NULL + AND COALESCE(ARRAY_LENGTH(peers, 1), 0) > 0 + AND (max_players = 0 OR ARRAY_LENGTH(peers, 1) < max_players) + ORDER BY created_at ASC, code ASC + LIMIT 1 + FOR UPDATE + `, game).Scan(&lobbyCode, &peerlist) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return "", nil, false, err + } + + created := errors.Is(err, pgx.ErrNoRows) + now := util.NowUTC(ctx) + if created { + for attempts := 0; attempts < 20; attempts++ { + lobbyCode = util.GenerateLobbyCodeWithFormat(ctx, codeFormat, attempts) + res, err := tx.Exec(ctx, ` + INSERT INTO lobbies (code, game, peers, public, created_at, updated_at, leader, term, can_update_by, creator, max_players, custom_data) + VALUES ($1, $2, $3, true, $4, $4, $5, 1, $6, $5, $7, $8) + ON CONFLICT DO NOTHING + `, lobbyCode, game, []string{peerID}, now, peerID, options.CanUpdateBy, options.MaxPlayers, options.CustomData) + if err != nil { + return "", nil, false, err + } + if res.RowsAffected() > 0 { + break + } + if attempts == 19 { + return "", nil, false, ErrLobbyExists + } + } + } else { + if slices.Contains(peerlist, peerID) { + return "", nil, false, ErrAlreadyInLobby + } + _, err = tx.Exec(ctx, ` + UPDATE lobbies + SET + peers = array_append(peers, $1), + updated_at = $2 + WHERE code = $3 + AND game = $4 + `, peerID, now, lobbyCode, game) + if err != nil { + return "", nil, false, err + } + } + + if err := tx.Commit(ctx); err != nil { + return "", nil, false, err + } + return lobbyCode, peerlist, created, nil +} + func (s *PostgresStore) LeaveLobby(ctx context.Context, game, lobbyCode, peerID string) error { now := util.NowUTC(ctx) diff --git a/internal/signaling/stores/shared.go b/internal/signaling/stores/shared.go index 97eda97..7e5eac8 100644 --- a/internal/signaling/stores/shared.go +++ b/internal/signaling/stores/shared.go @@ -28,6 +28,7 @@ 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) + QuickJoinLobby(ctx context.Context, game, peerID, codeFormat string, options LobbyOptions) (string, []string, bool, error) LeaveLobby(ctx context.Context, game, lobby, id string) error GetLobby(ctx context.Context, game, lobby string) (Lobby, error) ListLobbies(ctx context.Context, game string, country, region string, filter, sort string, limit int) ([]Lobby, error) diff --git a/internal/signaling/types.go b/internal/signaling/types.go index 4271c29..39aaa22 100644 --- a/internal/signaling/types.go +++ b/internal/signaling/types.go @@ -77,6 +77,16 @@ type JoinPacket struct { Password string `json:"password"` } +type QuickJoinPacket struct { + RequestID string `json:"rid"` + Type string `json:"type"` + + CodeFormat string `json:"codeFormat"` + MaxPlayers *int `json:"maxPlayers"` + CustomData map[string]any `json:"customData"` + CanUpdateBy string `json:"canUpdateBy"` +} + type JoinedPacket struct { RequestID string `json:"rid"` Type string `json:"type"` diff --git a/internal/util/identifiers.go b/internal/util/identifiers.go index a6034a0..b38a82a 100644 --- a/internal/util/identifiers.go +++ b/internal/util/identifiers.go @@ -69,3 +69,15 @@ func GenerateShortLobbyCode(ctx context.Context, chars int) string { } return strings.Join(ss, "") } + +// GenerateLobbyCodeWithFormat uses longer codes when earlier attempts collided. +func GenerateLobbyCodeWithFormat(ctx context.Context, format string, attempt int) string { + if format == "short" && attempt < 19 { + length := 4 + if attempt > 15 { + length = 5 + } + return GenerateShortLobbyCode(ctx, length) + } + return GenerateLobbyCode(ctx) +} diff --git a/lib/network.ts b/lib/network.ts index ce8274d..8860e12 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, PlayerCounts } from './types' +import { LobbyListEntry, LobbySettings, PeerConfiguration, PlayerCounts, QuickJoinSettings } from './types' import Signaling, { SignalingError } from './signaling' import Peer from './peer' import Credentials from './credentials' @@ -109,6 +109,20 @@ export default class Network extends EventEmitter { return undefined } + async quickJoin (settings?: QuickJoinSettings): Promise { + if (this._closing || this.signaling.receivedID === undefined) { + return undefined + } + const reply = await this.signaling.request({ + type: 'quickJoin', + ...settings + }) + if (reply.type === 'joined') { + return reply.lobbyInfo + } + return undefined + } + async setLobbySettings (settings: LobbySettings): Promise { if (this._closing || this.signaling.receivedID === undefined) { return new Error('network is closing or not connected') diff --git a/lib/types.ts b/lib/types.ts index d52ebe4..e80f54b 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -16,6 +16,8 @@ export interface LobbySettings { canUpdateBy?: 'creator' | 'leader' | 'anyone' | 'none' } +export type QuickJoinSettings = Omit + export interface LobbyListEntry { code: string public: boolean @@ -65,6 +67,7 @@ export type SignalingPacketTypes = | GetPlayerCountsPacket | PlayerCountsPacket | PingPacket +| QuickJoinPacket | WelcomePacket export interface PingPacket extends Base { @@ -115,6 +118,10 @@ export interface JoinPacket extends Base { password?: string } +export interface QuickJoinPacket extends Base, QuickJoinSettings { + type: 'quickJoin' +} + export interface JoinedPacket extends Base { type: 'joined' lobbyInfo: LobbyListEntry From 8a2c079d63129d874ef3c8b42b542af760d555bb Mon Sep 17 00:00:00 2001 From: Erik Dubbelboer Date: Sat, 24 Oct 2026 17:27:15 +0200 Subject: [PATCH 2/3] Fix suggestions --- README.md | 4 +- docs/api-reference.md | 27 ++++- features/join-or-create.feature | 168 ++++++++++++++++++++++++++ features/quick-join.feature | 59 --------- features/support/steps/network.ts | 10 +- internal/signaling/peer.go | 29 ++++- internal/signaling/stores/postgres.go | 53 ++++++-- internal/signaling/stores/shared.go | 2 +- internal/signaling/types.go | 4 +- internal/util/identifiers.go | 12 -- lib/network.ts | 9 +- lib/types.ts | 11 +- 12 files changed, 275 insertions(+), 113 deletions(-) create mode 100644 features/join-or-create.feature delete mode 100644 features/quick-join.feature diff --git a/README.md b/README.md index 789e0b3..395a981 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,11 @@ const network = new Network('') 3. Create or join a lobby: -For automatic matchmaking, quick-join the oldest available public lobby or create one atomically: +For automatic matchmaking, join a random available public lobby or create one atomically: ```javascript network.on('ready', async () => { - const lobby = await network.quickJoin({ maxPlayers: 4 }) + const lobby = await network.joinOrCreate({ maxPlayers: 4 }) console.log('Joined lobby:', lobby?.code) }) ``` diff --git a/docs/api-reference.md b/docs/api-reference.md index 4b0c80d..6e117c0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -38,18 +38,33 @@ interface LobbyOptions { ##### `join(code: string, password?: string): Promise` Joins an existing lobby. -##### `quickJoin(settings?: QuickJoinSettings): Promise` -Joins the oldest public, password-free, nonempty lobby with available capacity for this game, or creates one atomically. Simultaneous quick-join requests share lobbies without exceeding their capacity. Lobbies with the same creation time are ordered by code. +##### `joinOrCreate(settings?: JoinOrCreateSettings): Promise` +Joins a random public, password-free, nonempty lobby with available capacity for this game, or creates one atomically. Simultaneous join-or-create requests share lobbies without exceeding their capacity. ```typescript -type QuickJoinSettings = Omit; +interface JoinOrCreateSettings extends Omit { + filter?: object; +} -const lobby = await network.quickJoin({ maxPlayers: 4 }); +const lobby = await network.joinOrCreate({ + filter: { mode: "teams" }, + customData: { mode: "teams" }, + maxPlayers: 4 +}); ``` -`QuickJoinSettings` reuses the settings accepted by `create()`, except `public` and `password`. Settings apply only when creating a lobby; they do not filter or modify existing lobbies. This includes `codeFormat`, `maxPlayers`, `customData`, and `canUpdateBy`. New lobbies are always public and password-free. Defaults match `create()`, including four players and creator-only update permissions. Empty lobbies are excluded. The method returns `undefined` when the network is closing or not ready, matching `join()`. +Accepts the same settings as `create()`, except `public` and `password`, plus an optional `filter`. + +- `filter`: Selects existing lobbies using the same fields and MongoDB-style operators as `list()`. Invalid filters reject the request. +- `codeFormat`, `maxPlayers`, `customData`, `canUpdateBy`: Apply only when creating a lobby. Defaults match `create()`. + +Filters use `code`, `playerCount`, `createdAt`, `updatedAt`, and `latency` for lobby properties. Other fields refer to custom data. Latency is estimated from the joining player's location. + +If no lobby matches, creates a public, password-free lobby. Filter values are not copied into its custom data. Set `customData` explicitly, as shown above. + +Returns `undefined` when the network is closing or not ready. Existing lobby and connection events work as usual. -Use this instead of separate `list()`, `join()`, and `create()` calls for automatic matchmaking. Existing lobby and connection events work as usual. Callers using `create()` can still create separate lobbies. Deploy signaling server support before using this SDK method; older servers return an error and there is no client-side fallback. +Requires signaling server support. Older servers return an error. ##### `leave(): Promise` Leaves the current lobby without closing the network. diff --git a/features/join-or-create.feature b/features/join-or-create.feature new file mode 100644 index 0000000..d142bff --- /dev/null +++ b/features/join-or-create.feature @@ -0,0 +1,168 @@ +Feature: Players can atomically find or create a public lobby + + Background: + Given the "signaling" backend is running + + Scenario: Simultaneous join-or-create requests connect players in the same lobby + Given "blue" is connected as "1u8fw4aph5ypt" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + And "yellow" is connected as "h5yzwyizlwao" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + + When "blue,yellow" join or create simultaneously with a maximum of 2 players + Then "blue" receives the network event "lobby" with the argument "19yrzmetd2bn7" + And "yellow" receives the network event "lobby" with the argument "19yrzmetd2bn7" + And "blue" receives the network event "connected" with the argument "[Peer: h5yzwyizlwao]" + And "yellow" receives the network event "connected" with the argument "[Peer: 1u8fw4aph5ypt]" + + When "blue" boardcasts "Hello, world!" over the reliable channel + Then "yellow" receives the network event "message" with the arguments "[Peer: 1u8fw4aph5ypt]", "reliable" and "Hello, world!" + + When "blue" requests lobbies with: + """json + {} + """ + Then "blue" should have received only these lobbies: + | code | playerCount | maxPlayers | public | hasPassword | + | 19yrzmetd2bn7 | 2 | 2 | true | false | + + Scenario: Creation settings apply only when join-or-create creates a lobby + Given "blue" is connected as "1u8fw4aph5ypt" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + When "blue" joins or creates a lobby with these settings: + """json + { + "codeFormat": "short", + "maxPlayers": 3, + "customData": { + "mode": "teams" + }, + "canUpdateBy": "none" + } + """ + Then "blue" receives the network event "lobby" with the argument "SHC6" + + When "yellow" creates a network for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + Then "yellow" receives the network event "ready" + When "yellow" joins or creates a lobby with these settings: + """json + { + "codeFormat": "default", + "maxPlayers": 1, + "customData": { + "mode": "solo" + }, + "canUpdateBy": "anyone" + } + """ + Then "yellow" receives the network event "lobby" with the argument "SHC6" + + When "blue" requests lobbies with: + """json + {} + """ + Then "blue" should have received only these lobbies: + | code | playerCount | maxPlayers | public | hasPassword | customData | canUpdateBy | + | SHC6 | 2 | 3 | true | false | {"mode":"teams"} | none | + + Scenario: Join-or-create filters existing lobbies by custom data + Given the "testproxy" backend is running + Given "blue" is connected as "1u8fw4aph5ypt" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + And these lobbies exist: + | code | game | playerCount | custom_data | public | + | 0qva9vyurwbbl | 4307bd86-e1df-41b8-b9df-e22afcf084bd | 1 | {"mode":"solo","level":5} | true | + | 1qva9vyurwbbl | 4307bd86-e1df-41b8-b9df-e22afcf084bd | 1 | {"mode":"teams","level":1} | true | + | 2qva9vyurwbbl | 4307bd86-e1df-41b8-b9df-e22afcf084bd | 1 | {"mode":"teams","level":5} | true | + | 3qva9vyurwbbl | 4307bd86-e1df-41b8-b9df-e22afcf084bd | 1 | {"mode":"teams","level":5} | false | + When "blue" joins or creates a lobby with these settings: + """json + { + "filter": { + "$or": [ + { + "mode": "teams" + }, + { + "mode": "duo" + } + ], + "level": { + "$gte": 3 + } + }, + "customData": { + "mode": "unused" + } + } + """ + Then "blue" receives the network event "lobby" with the argument "2qva9vyurwbbl" + + Scenario: Join-or-create creates a lobby when no custom data matches + Given the "testproxy" backend is running + Given "blue" is connected as "1u8fw4aph5ypt" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + And these lobbies exist: + | code | game | playerCount | custom_data | public | + | 0qva9vyurwbbl | 4307bd86-e1df-41b8-b9df-e22afcf084bd | 1 | {"mode":"solo"} | true | + When "blue" joins or creates a lobby with these settings: + """json + { + "filter": { + "mode": "teams" + }, + "codeFormat": "short", + "customData": { + "mode": "teams", + "level": 5 + } + } + """ + Then "blue" receives the network event "lobby" with the argument "SHC6" + When "blue" requests lobbies with: + """json + { + "mode": "teams" + } + """ + Then "blue" should have received only these lobbies: + | code | playerCount | customData | + | SHC6 | 1 | {"level":5,"mode":"teams"} | + + Scenario Outline: Join-or-create uses the same lobby fields as list + Given the "testproxy" backend is running + Given "blue" is connected as "1u8fw4aph5ypt" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + And these lobbies exist: + | code | game | playerCount | created_at | updated_at | custom_data | public | + | 2qva9vyurwbbl | 4307bd86-e1df-41b8-b9df-e22afcf084bd | 2 | 2026-01-01T00:00:00Z | 2026-01-02T00:00:00Z | {"code":"different","playerCount":99,"createdAt":null,"updatedAt":null,"latency":999} | true | + When "blue" joins or creates a lobby with these settings: + """json + { + "filter": + } + """ + Then "blue" receives the network event "lobby" with the argument "2qva9vyurwbbl" + + Examples: + | filter | + | {"code":"2qva9vyurwbbl"} | + | {"playerCount":{"$gte":2,"$lt":3}} | + | {"createdAt":{"$gte":"2026-01-01","$lt":"2026-01-02"}} | + | {"updatedAt":{"$gte":"2026-01-02","$lt":"2026-01-03"}} | + | {"latency":{"$lte":250}} | + + Scenario: Join-or-create filters latency from the joining player's location + Given the "testproxy" backend is running + And these lobbies exist: + | code | game | peers | public | + | 0qva9vyurwbbl | 123e4567-e89b-12d3-a456-426614174000 | {peerA} | true | + And these peers exist: + | peer | game | country | region | + | peerA | 123e4567-e89b-12d3-a456-426614174000 | US | US-CA | + And "blue" is connected as "1u8fw4aph5ypt" with country,region as "US","US-CA" and ready for game "123e4567-e89b-12d3-a456-426614174000" + When "blue" joins or creates a lobby with these settings: + """json + { + "filter": { + "latency": { + "$lt": 100 + } + } + } + """ + Then "blue" receives the network event "lobby" with the argument "0qva9vyurwbbl" diff --git a/features/quick-join.feature b/features/quick-join.feature deleted file mode 100644 index decfa74..0000000 --- a/features/quick-join.feature +++ /dev/null @@ -1,59 +0,0 @@ -Feature: Players can atomically find or create a public lobby - - Background: - Given the "signaling" backend is running - - Scenario: Simultaneous quick-joins connect players in the same lobby - Given "blue" is connected as "1u8fw4aph5ypt" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" - And "yellow" is connected as "h5yzwyizlwao" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" - - When "blue,yellow" quick-join simultaneously with a maximum of 2 players - Then "blue" receives the network event "lobby" with the argument "19yrzmetd2bn7" - And "yellow" receives the network event "lobby" with the argument "19yrzmetd2bn7" - And "blue" receives the network event "connected" with the argument "[Peer: h5yzwyizlwao]" - And "yellow" receives the network event "connected" with the argument "[Peer: 1u8fw4aph5ypt]" - - When "blue" boardcasts "Hello, world!" over the reliable channel - Then "yellow" receives the network event "message" with the arguments "[Peer: 1u8fw4aph5ypt]", "reliable" and "Hello, world!" - - When "blue" requests lobbies with: - """json - {} - """ - Then "blue" should have received only these lobbies: - | code | playerCount | maxPlayers | public | hasPassword | - | 19yrzmetd2bn7 | 2 | 2 | true | false | - - Scenario: Creation settings apply only when quick-join creates a lobby - Given "blue" is connected as "1u8fw4aph5ypt" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" - When "blue" quick-joins with these settings: - """json - { - "codeFormat": "short", - "maxPlayers": 3, - "customData": { "mode": "teams" }, - "canUpdateBy": "none" - } - """ - Then "blue" receives the network event "lobby" with the argument "SHC6" - - When "yellow" creates a network for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" - Then "yellow" receives the network event "ready" - When "yellow" quick-joins with these settings: - """json - { - "codeFormat": "default", - "maxPlayers": 1, - "customData": { "mode": "solo" }, - "canUpdateBy": "anyone" - } - """ - Then "yellow" receives the network event "lobby" with the argument "SHC6" - - When "blue" requests lobbies with: - """json - {} - """ - Then "blue" should have received only these lobbies: - | code | playerCount | maxPlayers | public | hasPassword | customData | canUpdateBy | - | SHC6 | 2 | 3 | true | false | {"mode":"teams"} | none | diff --git a/features/support/steps/network.ts b/features/support/steps/network.ts index f47aefa..0e7a931 100644 --- a/features/support/steps/network.ts +++ b/features/support/steps/network.ts @@ -194,24 +194,24 @@ When('{string} creates a lobby with these settings:', async function (this: Worl await player.network.create(settings) }) -When('{string} quick-joins with these settings:', async function (this: World, playerName: string, settingsBlob: string) { +When('{string} joins or creates a lobby with these settings:', async function (this: World, playerName: string, settingsBlob: string) { const player = this.players.get(playerName) if (player == null) { throw new Error('no such player') } const settings = JSON.parse(settingsBlob) - await player.network.quickJoin(settings) + await player.network.joinOrCreate(settings) }) -When('{string} quick-join simultaneously with a maximum of {int} players', async function (this: World, playerNamesRaw: string, maxPlayers: number) { +When('{string} join or create simultaneously with a maximum of {int} players', async function (this: World, playerNamesRaw: string, maxPlayers: number) { await Promise.all(playerNamesRaw.split(',').map(async playerName => { const player = this.players.get(playerName.trim()) if (player == null) { throw new Error('no such player') } - const lobby = await player.network.quickJoin({ maxPlayers }) + const lobby = await player.network.joinOrCreate({ maxPlayers }) if (lobby === undefined || lobby.code !== player.network.currentLobby) { - throw new Error('quick-join did not return the current lobby') + throw new Error('join-or-create did not return the current lobby') } })) }) diff --git a/internal/signaling/peer.go b/internal/signaling/peer.go index a3c296b..032811c 100644 --- a/internal/signaling/peer.go +++ b/internal/signaling/peer.go @@ -159,12 +159,12 @@ func (p *Peer) HandlePacket(ctx context.Context, typ string, raw []byte) error { return fmt.Errorf("unable to handle packet: %w", err) } - case "quickJoin": - packet := QuickJoinPacket{} + case "joinOrCreate": + packet := JoinOrCreatePacket{} if err := json.Unmarshal(raw, &packet); err != nil { return fmt.Errorf("unable to unmarshal json: %w", err) } - err = p.HandleQuickJoinPacket(ctx, packet) + err = p.HandleJoinOrCreatePacket(ctx, packet) if err != nil { return fmt.Errorf("unable to handle packet: %w", err) } @@ -448,7 +448,7 @@ func (p *Peer) HandleCreatePacket(ctx context.Context, packet CreatePacket) erro attempts := 0 for ; attempts < 20; attempts++ { - p.Lobby = util.GenerateLobbyCodeWithFormat(ctx, packet.CodeFormat, attempts) + p.Lobby = generateLobbyCodeWithFormat(ctx, packet.CodeFormat, attempts) err := p.store.CreateLobby(ctx, p.Game, p.Lobby, p.ID, stores.LobbyOptions{ Public: &packet.Public, @@ -524,7 +524,7 @@ func (p *Peer) HandleJoinPacket(ctx context.Context, packet JoinPacket) error { return p.finishJoin(ctx, packet.RequestID, packet.Lobby, existingPeers, "joined") } -func (p *Peer) HandleQuickJoinPacket(ctx context.Context, packet QuickJoinPacket) error { +func (p *Peer) HandleJoinOrCreatePacket(ctx context.Context, packet JoinOrCreatePacket) error { if p.ID == "" { return fmt.Errorf("peer not connected") } @@ -551,7 +551,9 @@ func (p *Peer) HandleQuickJoinPacket(ctx context.Context, packet QuickJoinPacket return fmt.Errorf("invalid maxPlayers value") } - lobbyCode, existingPeers, created, err := p.store.QuickJoinLobby(ctx, p.Game, p.ID, packet.CodeFormat, stores.LobbyOptions{ + lobbyCode, existingPeers, created, err := p.store.JoinOrCreateLobby(ctx, p.Game, p.ID, p.Country, p.Region, packet.Filter, func(attempt int) string { + return generateLobbyCodeWithFormat(ctx, packet.CodeFormat, attempt) + }, stores.LobbyOptions{ CustomData: &packet.CustomData, CanUpdateBy: &packet.CanUpdateBy, MaxPlayers: &maxPlayers, @@ -700,3 +702,18 @@ func (p *Peer) doLeaderElectionAndPublish(ctx context.Context) (bool, error) { return result != nil, nil } + +// generateLobbyCodeWithFormat generates a lobby code based on the specified format. +// It uses longer codes when earlier attempts collided. +func generateLobbyCodeWithFormat(ctx context.Context, format string, attempt int) string { + if format == "short" && attempt < 19 { + length := 4 + if attempt > 15 { + length = 5 + } + + return util.GenerateShortLobbyCode(ctx, length) + } + + return util.GenerateLobbyCode(ctx) +} diff --git a/internal/signaling/stores/postgres.go b/internal/signaling/stores/postgres.go index 321d707..ab7073e 100644 --- a/internal/signaling/stores/postgres.go +++ b/internal/signaling/stores/postgres.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "errors" "fmt" + "hash/fnv" "math/rand" "os" "regexp" @@ -276,20 +277,36 @@ func (s *PostgresStore) JoinLobby(ctx context.Context, game, lobbyCode, peerID, return peerlist, nil } -func (s *PostgresStore) QuickJoinLobby(ctx context.Context, game, peerID, codeFormat string, options LobbyOptions) (string, []string, bool, error) { +func (s *PostgresStore) JoinOrCreateLobby(ctx context.Context, game, peerID, country, region, lobbyFilter string, generateCode func(attempt int) string, options LobbyOptions) (string, []string, bool, error) { if len(peerID) > 20 { return "", nil, false, ErrInvalidPeerID } + if lobbyFilter == "" { + lobbyFilter = "{}" + } + where, values, err := s.filterConverter.Convert([]byte(lobbyFilter), 4) + if err != nil { + return "", nil, false, fmt.Errorf("invalid filter: %w", err) + } + tx, err := s.DB.Begin(ctx) if err != nil { return "", nil, false, err } defer tx.Rollback(context.Background()) //nolint:errcheck + h := fnv.New64a() + _, _ = h.Write([]byte(game)) + hash := int64(h.Sum64()) + // Lock the game before looking for a lobby, including when none exists yet. - // Use the canonical UUID so different spellings of the same game share a lock. - _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended('quickJoin:' || $1::uuid::text, 0))`, game) + // + // The SELECT FOR UPDATE below will only lock an existing lobby row, it won't help us + // with the race condition where 2 players both don't find a lobby and try to create one simultaneously. + // + // pg_advisory_xact_lock automatically releases the lock at the end of the transaction. + _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, hash) if err != nil { return "", nil, false, err } @@ -298,25 +315,33 @@ func (s *PostgresStore) QuickJoinLobby(ctx context.Context, game, peerID, codeFo var peerlist []string err = tx.QueryRow(ctx, ` SELECT code, peers - FROM lobbies - WHERE game = $1 - AND public = true + FROM ( + SELECT *, + 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 + ) AS game_lobbies + WHERE public = true AND password IS NULL AND COALESCE(ARRAY_LENGTH(peers, 1), 0) > 0 AND (max_players = 0 OR ARRAY_LENGTH(peers, 1) < max_players) - ORDER BY created_at ASC, code ASC + AND (`+where+`) + ORDER BY RANDOM() LIMIT 1 FOR UPDATE - `, game).Scan(&lobbyCode, &peerlist) + `, append([]any{game, country, region}, values...)...).Scan(&lobbyCode, &peerlist) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return "", nil, false, err } - created := errors.Is(err, pgx.ErrNoRows) + exists := !errors.Is(err, pgx.ErrNoRows) now := util.NowUTC(ctx) - if created { - for attempts := 0; attempts < 20; attempts++ { - lobbyCode = util.GenerateLobbyCodeWithFormat(ctx, codeFormat, attempts) + if !exists { + for attempts := range 20 { + lobbyCode = generateCode(attempts) res, err := tx.Exec(ctx, ` INSERT INTO lobbies (code, game, peers, public, created_at, updated_at, leader, term, can_update_by, creator, max_players, custom_data) VALUES ($1, $2, $3, true, $4, $4, $5, 1, $6, $5, $7, $8) @@ -336,6 +361,7 @@ func (s *PostgresStore) QuickJoinLobby(ctx context.Context, game, peerID, codeFo if slices.Contains(peerlist, peerID) { return "", nil, false, ErrAlreadyInLobby } + _, err = tx.Exec(ctx, ` UPDATE lobbies SET @@ -352,7 +378,8 @@ func (s *PostgresStore) QuickJoinLobby(ctx context.Context, game, peerID, codeFo if err := tx.Commit(ctx); err != nil { return "", nil, false, err } - return lobbyCode, peerlist, created, nil + + return lobbyCode, peerlist, !exists, nil } func (s *PostgresStore) LeaveLobby(ctx context.Context, game, lobbyCode, peerID string) error { diff --git a/internal/signaling/stores/shared.go b/internal/signaling/stores/shared.go index 7e5eac8..6a048ba 100644 --- a/internal/signaling/stores/shared.go +++ b/internal/signaling/stores/shared.go @@ -28,7 +28,7 @@ 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) - QuickJoinLobby(ctx context.Context, game, peerID, codeFormat string, options LobbyOptions) (string, []string, bool, error) + JoinOrCreateLobby(ctx context.Context, game, peerID, country, region, lobbyFilter string, generateCode func(attempt int) string, options LobbyOptions) (string, []string, bool, error) LeaveLobby(ctx context.Context, game, lobby, id string) error GetLobby(ctx context.Context, game, lobby string) (Lobby, error) ListLobbies(ctx context.Context, game string, country, region string, filter, sort string, limit int) ([]Lobby, error) diff --git a/internal/signaling/types.go b/internal/signaling/types.go index 39aaa22..1c4d784 100644 --- a/internal/signaling/types.go +++ b/internal/signaling/types.go @@ -77,7 +77,9 @@ type JoinPacket struct { Password string `json:"password"` } -type QuickJoinPacket struct { +type JoinOrCreatePacket struct { + Filter string `json:"filter"` + RequestID string `json:"rid"` Type string `json:"type"` diff --git a/internal/util/identifiers.go b/internal/util/identifiers.go index b38a82a..a6034a0 100644 --- a/internal/util/identifiers.go +++ b/internal/util/identifiers.go @@ -69,15 +69,3 @@ func GenerateShortLobbyCode(ctx context.Context, chars int) string { } return strings.Join(ss, "") } - -// GenerateLobbyCodeWithFormat uses longer codes when earlier attempts collided. -func GenerateLobbyCodeWithFormat(ctx context.Context, format string, attempt int) string { - if format == "short" && attempt < 19 { - length := 4 - if attempt > 15 { - length = 5 - } - return GenerateShortLobbyCode(ctx, length) - } - return GenerateLobbyCode(ctx) -} diff --git a/lib/network.ts b/lib/network.ts index 8860e12..f8943c3 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, PlayerCounts, QuickJoinSettings } from './types' +import { LobbyListEntry, LobbySettings, PeerConfiguration, PlayerCounts, JoinOrCreateSettings } from './types' import Signaling, { SignalingError } from './signaling' import Peer from './peer' import Credentials from './credentials' @@ -109,13 +109,14 @@ export default class Network extends EventEmitter { return undefined } - async quickJoin (settings?: QuickJoinSettings): Promise { + async joinOrCreate (settings?: JoinOrCreateSettings): Promise { if (this._closing || this.signaling.receivedID === undefined) { return undefined } const reply = await this.signaling.request({ - type: 'quickJoin', - ...settings + type: 'joinOrCreate', + ...settings, + filter: settings?.filter != null ? JSON.stringify(settings.filter) : undefined }) if (reply.type === 'joined') { return reply.lobbyInfo diff --git a/lib/types.ts b/lib/types.ts index e80f54b..6a831c4 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -16,7 +16,9 @@ export interface LobbySettings { canUpdateBy?: 'creator' | 'leader' | 'anyone' | 'none' } -export type QuickJoinSettings = Omit +export interface JoinOrCreateSettings extends Omit { + filter?: object // MongoDB-style filter on existing lobbies, with the same fields as list(). +} export interface LobbyListEntry { code: string @@ -67,7 +69,7 @@ export type SignalingPacketTypes = | GetPlayerCountsPacket | PlayerCountsPacket | PingPacket -| QuickJoinPacket +| JoinOrCreatePacket | WelcomePacket export interface PingPacket extends Base { @@ -118,8 +120,9 @@ export interface JoinPacket extends Base { password?: string } -export interface QuickJoinPacket extends Base, QuickJoinSettings { - type: 'quickJoin' +export interface JoinOrCreatePacket extends Base, Omit { + type: 'joinOrCreate' + filter?: string } export interface JoinedPacket extends Base { From a12ec68201fa442d8285e7f2e5b320108579cae0 Mon Sep 17 00:00:00 2001 From: Erik Dubbelboer Date: Wed, 28 Oct 2026 14:13:40 +0100 Subject: [PATCH 3/3] Remove func arg --- internal/signaling/peer.go | 21 ++------------------- internal/signaling/stores/postgres.go | 4 ++-- internal/signaling/stores/shared.go | 2 +- internal/util/identifiers.go | 15 +++++++++++++++ 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/internal/signaling/peer.go b/internal/signaling/peer.go index 032811c..5833b34 100644 --- a/internal/signaling/peer.go +++ b/internal/signaling/peer.go @@ -448,7 +448,7 @@ func (p *Peer) HandleCreatePacket(ctx context.Context, packet CreatePacket) erro attempts := 0 for ; attempts < 20; attempts++ { - p.Lobby = generateLobbyCodeWithFormat(ctx, packet.CodeFormat, attempts) + p.Lobby = util.GenerateLobbyCodeWithFormat(ctx, packet.CodeFormat, attempts) err := p.store.CreateLobby(ctx, p.Game, p.Lobby, p.ID, stores.LobbyOptions{ Public: &packet.Public, @@ -551,9 +551,7 @@ func (p *Peer) HandleJoinOrCreatePacket(ctx context.Context, packet JoinOrCreate return fmt.Errorf("invalid maxPlayers value") } - lobbyCode, existingPeers, created, err := p.store.JoinOrCreateLobby(ctx, p.Game, p.ID, p.Country, p.Region, packet.Filter, func(attempt int) string { - return generateLobbyCodeWithFormat(ctx, packet.CodeFormat, attempt) - }, stores.LobbyOptions{ + lobbyCode, existingPeers, created, err := p.store.JoinOrCreateLobby(ctx, p.Game, p.ID, p.Country, p.Region, packet.Filter, packet.CodeFormat, stores.LobbyOptions{ CustomData: &packet.CustomData, CanUpdateBy: &packet.CanUpdateBy, MaxPlayers: &maxPlayers, @@ -702,18 +700,3 @@ func (p *Peer) doLeaderElectionAndPublish(ctx context.Context) (bool, error) { return result != nil, nil } - -// generateLobbyCodeWithFormat generates a lobby code based on the specified format. -// It uses longer codes when earlier attempts collided. -func generateLobbyCodeWithFormat(ctx context.Context, format string, attempt int) string { - if format == "short" && attempt < 19 { - length := 4 - if attempt > 15 { - length = 5 - } - - return util.GenerateShortLobbyCode(ctx, length) - } - - return util.GenerateLobbyCode(ctx) -} diff --git a/internal/signaling/stores/postgres.go b/internal/signaling/stores/postgres.go index ab7073e..d5c9c19 100644 --- a/internal/signaling/stores/postgres.go +++ b/internal/signaling/stores/postgres.go @@ -277,7 +277,7 @@ func (s *PostgresStore) JoinLobby(ctx context.Context, game, lobbyCode, peerID, return peerlist, nil } -func (s *PostgresStore) JoinOrCreateLobby(ctx context.Context, game, peerID, country, region, lobbyFilter string, generateCode func(attempt int) string, options LobbyOptions) (string, []string, bool, error) { +func (s *PostgresStore) JoinOrCreateLobby(ctx context.Context, game, peerID, country, region, lobbyFilter, codeFormat string, options LobbyOptions) (string, []string, bool, error) { if len(peerID) > 20 { return "", nil, false, ErrInvalidPeerID } @@ -341,7 +341,7 @@ func (s *PostgresStore) JoinOrCreateLobby(ctx context.Context, game, peerID, cou now := util.NowUTC(ctx) if !exists { for attempts := range 20 { - lobbyCode = generateCode(attempts) + lobbyCode = util.GenerateLobbyCodeWithFormat(ctx, codeFormat, attempts) res, err := tx.Exec(ctx, ` INSERT INTO lobbies (code, game, peers, public, created_at, updated_at, leader, term, can_update_by, creator, max_players, custom_data) VALUES ($1, $2, $3, true, $4, $4, $5, 1, $6, $5, $7, $8) diff --git a/internal/signaling/stores/shared.go b/internal/signaling/stores/shared.go index 6a048ba..9a898ca 100644 --- a/internal/signaling/stores/shared.go +++ b/internal/signaling/stores/shared.go @@ -28,7 +28,7 @@ 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) - JoinOrCreateLobby(ctx context.Context, game, peerID, country, region, lobbyFilter string, generateCode func(attempt int) string, options LobbyOptions) (string, []string, bool, error) + JoinOrCreateLobby(ctx context.Context, game, peerID, country, region, lobbyFilter, codeFormat string, options LobbyOptions) (string, []string, bool, error) LeaveLobby(ctx context.Context, game, lobby, id string) error GetLobby(ctx context.Context, game, lobby string) (Lobby, error) ListLobbies(ctx context.Context, game string, country, region string, filter, sort string, limit int) ([]Lobby, error) diff --git a/internal/util/identifiers.go b/internal/util/identifiers.go index a6034a0..7a1749a 100644 --- a/internal/util/identifiers.go +++ b/internal/util/identifiers.go @@ -69,3 +69,18 @@ func GenerateShortLobbyCode(ctx context.Context, chars int) string { } return strings.Join(ss, "") } + +// GenerateLobbyCodeWithFormat generates a lobby code based on the specified format. +// It uses longer codes when earlier attempts collided. +func GenerateLobbyCodeWithFormat(ctx context.Context, format string, attempt int) string { + if format == "short" && attempt < 19 { + length := 4 + if attempt > 15 { + length = 5 + } + + return GenerateShortLobbyCode(ctx, length) + } + + return GenerateLobbyCode(ctx) +}