diff --git a/features/custom-data.feature b/features/custom-data.feature index e5d7fca..1cd45d6 100644 --- a/features/custom-data.feature +++ b/features/custom-data.feature @@ -28,12 +28,14 @@ Feature: customData on lobbies can be used for filtering and extra information "h5yzwyizlwao" ], "playerCount": 1, + "creator": "h5yzwyizlwao", "public": true, "maxPlayers": 0, "customData": { "gameMode": "deathmatch", "map": "de_dust2" }, + "canUpdateBy": "creator", "leader": "h5yzwyizlwao", "term": 1 } @@ -52,14 +54,185 @@ Feature: customData on lobbies can be used for filtering and extra information "h5yzwyizlwao" ], "playerCount": 2, + "creator": "h5yzwyizlwao", "public": true, "maxPlayers": 0, "customData": { "gameMode": "deathmatch", "map": "de_dust2" }, + "canUpdateBy": "creator", "leader": "h5yzwyizlwao", "term": 1 } ] """ + + + Scenario: The creator can edit a lobby + Given "blue" is connected as "h5yzwyizlwao" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + And "yellow" is connected as "3t3cfgcqup9e" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + And "yellow" creates a lobby with these settings: + """json + { + "public": true, + "customData": { + "status": "open" + } + } + """ + And "yellow" receives the network event "lobby" with the argument "prb67ouj837u" + + When "blue" requests lobbies with this filter: + """json + { + "status": "open" + } + """ + Then "blue" should have received only these lobbies: + | code | + | prb67ouj837u | + + When "yellow" updates the lobby with these settings: + """json + { + "customData": { + "status": "started" + } + } + """ + + When "blue" requests lobbies with this filter: + """json + { + "status": "open" + } + """ + Then "blue" should have received only these lobbies: + | code | + + + Scenario: The creator can set can_update_by + Given "blue" is connected as "h5yzwyizlwao" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + And "blue" creates a lobby with these settings: + """json + { + "public": true, + "canUpdateBy": "creator" + } + """ + And "blue" receives the network event "lobby" with the argument "19yrzmetd2bn7" + + When "blue" updates the lobby with these settings: + """json + { + "canUpdateBy": "none" + } + """ + Then "blue" fails to update the lobby with these settings: + """json + { + "customData": { + "status": "started" + } + } + """ + + + Scenario: Other players can update the lobby if canUpdateBy is 'anyone' + Given "blue" is connected as "h5yzwyizlwao" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + And "yellow" is connected as "3t3cfgcqup9e" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + And "blue" creates a lobby with these settings: + """json + { + "canUpdateBy": "anyone" + } + """ + And "blue" receives the network event "lobby" with the argument "prb67ouj837u" + And "yellow" connects to the lobby "prb67ouj837u" + And "yellow" receives the network event "lobby" with the argument "prb67ouj837u" + + When "yellow" updates the lobby with these settings: + """json + { + "customData": { + "status": "started" + } + } + """ + Then "yellow" receives the network event "lobbyUpdated" with the argument "prb67ouj837u" + + + Scenario: The creator can update the lobby when canUpdateBy is 'creator' and they are not the leader + Given "blue" is connected as "h5yzwyizlwao" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + And "yellow" is connected as "3t3cfgcqup9e" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + And "blue" creates a lobby with these settings: + """json + { + "canUpdateBy": "creator" + } + """ + And "blue" receives the network event "lobby" with the argument "prb67ouj837u" + And "yellow" connects to the lobby "prb67ouj837u" + And "blue" disconnected from the signaling server + And "yellow" becomes the leader of the lobby + And "blue" receives the network event "signalingreconnected" + + When "blue" updates the lobby with these settings: + """json + { + "customData": { + "status": "started" + } + } + """ + Then "blue" receives the network event "lobbyUpdated" with the argument "prb67ouj837u" + And "yellow" receives the network event "lobbyUpdated" with the arguments: + """json + [ + "prb67ouj837u", + { + "code": "prb67ouj837u", + "peers": [ + "3t3cfgcqup9e", + "h5yzwyizlwao" + ], + "playerCount": 2, + "creator": "h5yzwyizlwao", + "public": false, + "maxPlayers": 0, + "customData": { + "status": "started" + }, + "canUpdateBy": "creator", + "leader": "3t3cfgcqup9e", + "term": 2 + } + ] + """ + + + Scenario: The leader can update the lobby if they are not the creator + Given "blue" is connected as "h5yzwyizlwao" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + And "yellow" is connected as "3t3cfgcqup9e" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" + And "blue" creates a lobby with these settings: + """json + { + "canUpdateBy": "leader" + } + """ + And "blue" receives the network event "lobby" with the argument "prb67ouj837u" + And "yellow" connects to the lobby "prb67ouj837u" + And "blue" disconnects + And "blue" receives the network event "close" + And "yellow" becomes the leader of the lobby + + When "yellow" updates the lobby with these settings: + """json + { + "customData": { + "status": "started" + } + } + """ + Then "yellow" receives the network event "lobbyUpdated" with the argument "prb67ouj837u" diff --git a/features/leader.feature b/features/leader.feature index 089c6cb..a329078 100644 --- a/features/leader.feature +++ b/features/leader.feature @@ -23,9 +23,11 @@ Feature: Lobbies have a leader that can control the lobby "h5yzwyizlwao" ], "playerCount": 2, + "creator": "h5yzwyizlwao", "public": false, "maxPlayers": 0, "customData": null, + "canUpdateBy": "creator", "leader": "h5yzwyizlwao", "term": 1 } @@ -46,8 +48,8 @@ Feature: Lobbies have a leader that can control the lobby Scenario: Joining an empty lobby makes you the leader Given "blue" is connected as "h5yzwyizlwao" and ready for game "4307bd86-e1df-41b8-b9df-e22afcf084bd" And these lobbies exist: - | code | game | playerCount | public | custom_data | - | 1qva9vyurwbb | 4307bd86-e1df-41b8-b9df-e22afcf084bd | 0 | true | {"map": "de_nuke"} | + | code | game | playerCount | public | custom_data | creator | + | 1qva9vyurwbb | 4307bd86-e1df-41b8-b9df-e22afcf084bd | 0 | true | {"map": "de_nuke"} | foo | When "blue" connects to the lobby "1qva9vyurwbb" And "blue" receives the network event "lobby" with the arguments: @@ -60,11 +62,13 @@ Feature: Lobbies have a leader that can control the lobby "h5yzwyizlwao" ], "playerCount": 1, + "creator": "foo", "public": true, "maxPlayers": 0, "customData": { "map": "de_nuke" }, + "canUpdateBy": "creator", "leader": "h5yzwyizlwao", "term": 1 } diff --git a/features/support/steps/network.ts b/features/support/steps/network.ts index 7d64a2b..c32c3ab 100644 --- a/features/support/steps/network.ts +++ b/features/support/steps/network.ts @@ -331,3 +331,27 @@ Given('{string} becomes the leader of the lobby', async function (this: World, p throw new Error('player is not the leader') } }) + +When('{string} updates the lobby with these settings:', async function (this: World, playerName: string, settings: string) { + const player = this.players.get(playerName) + if (player == null) { + throw new Error('no such player') + } + const r = await player.network.setLobbySettings(JSON.parse(settings)) + if (r !== true) { + throw new Error(`failed to update lobby: ${r.message}`) + } +}) + +When('{string} fails to update the lobby with these settings:', async function (this: World, playerName: string, settings: string) { + const player = this.players.get(playerName) + if (player == null) { + throw new Error('no such player') + } + try { + await player.network.setLobbySettings(JSON.parse(settings)) + } catch (e) { + return // we expect this to fail + } + throw new Error('no error thrown') +}) diff --git a/features/support/types.ts b/features/support/types.ts index 68d1b71..8092d03 100644 --- a/features/support/types.ts +++ b/features/support/types.ts @@ -6,7 +6,7 @@ interface RecordedEvent { eventPayload: IArguments } -const allEvents = ['close', 'ready', 'lobby', 'connected', 'disconnected', 'reconnecting', 'reconnected', 'message', 'signalingerror', 'signalingreconnected', 'leader'] +const allEvents = ['close', 'ready', 'lobby', 'connected', 'disconnected', 'reconnecting', 'reconnected', 'message', 'signalingerror', 'signalingreconnected', 'leader', 'lobbyUpdated'] export class Player { public lastReceivedLobbies: LobbyListEntry[] = [] diff --git a/features/support/world.ts b/features/support/world.ts index d421605..0934264 100644 --- a/features/support/world.ts +++ b/features/support/world.ts @@ -88,6 +88,11 @@ AfterAll(function (this: World) { // a quick workaround to make sure the process is killed neatly. // source: https://github.com/node-webrtc/node-webrtc/issues/636#issuecomment-774171409 process.on('beforeExit', (code) => process.exit(code)) + + setTimeout(() => { + console.log('cucumber did not exit cleanly, forcing exit') + process.exit(0) + }, 1000).unref() }) Before(function (this: World) { diff --git a/internal/signaling/handler.go b/internal/signaling/handler.go index fb0a12a..7b8c704 100644 --- a/internal/signaling/handler.go +++ b/internal/signaling/handler.go @@ -112,6 +112,10 @@ func Handler(ctx context.Context, store stores.Store, cloudflare *cloudflare.Cre util.ErrorAndDisconnect(ctx, conn, err) } + if base.RequestID != "" { + ctx = util.WithRequestID(ctx, base.RequestID) + } + if peer.closedPacketReceived { if base.Type != "disconnect" && base.Type != "disconnected" { // expected lingering packets after closure. logger.Warn("received packet after close", zap.String("peer", peer.ID), zap.String("type", base.Type)) diff --git a/internal/signaling/peer.go b/internal/signaling/peer.go index 0d380dc..307798a 100644 --- a/internal/signaling/peer.go +++ b/internal/signaling/peer.go @@ -134,6 +134,16 @@ func (p *Peer) HandlePacket(ctx context.Context, typ string, raw []byte) error { return fmt.Errorf("unable to handle packet: %w", err) } + case "lobbyUpdate": + packet := LobbyUpdatePacket{} + if err := json.Unmarshal(raw, &packet); err != nil { + return fmt.Errorf("unable to unmarshal json: %w", err) + } + err = p.HandleUpdatePacket(ctx, packet) + if err != nil { + return fmt.Errorf("unable to handle packet: %w", err) + } + // case "leave": case "connected": // TODO: Do we want to keep track of connections between peers? @@ -325,6 +335,17 @@ func (p *Peer) HandleCreatePacket(ctx context.Context, packet CreatePacket) erro 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") + } + } + attempts := 20 for ; attempts > 0; attempts-- { switch packet.CodeFormat { @@ -334,7 +355,7 @@ func (p *Peer) HandleCreatePacket(ctx context.Context, packet CreatePacket) erro p.Lobby = util.GenerateLobbyCode(ctx) } - err := p.store.CreateLobby(ctx, p.Game, p.Lobby, p.ID, packet.Public, packet.CustomData) + err := p.store.CreateLobby(ctx, p.Game, p.Lobby, p.ID, packet.Public, packet.CustomData, packet.CanUpdateBy) if err != nil { if err == stores.ErrLobbyExists { continue @@ -430,6 +451,49 @@ func (p *Peer) HandleJoinPacket(ctx context.Context, packet JoinPacket) error { return nil } +func (p *Peer) HandleUpdatePacket(ctx context.Context, packet LobbyUpdatePacket) error { + logger := logging.GetLogger(ctx) + if p.ID == "" { + return fmt.Errorf("peer not connected") + } + if p.Lobby == "" { + return fmt.Errorf("not in a lobby") + } + if packet.CanUpdateBy != nil { + if *packet.CanUpdateBy != stores.CanUpdateByCreator && + *packet.CanUpdateBy != stores.CanUpdateByLeader && + *packet.CanUpdateBy != stores.CanUpdateByAnyone && + *packet.CanUpdateBy != stores.CanUpdateByNone { + return fmt.Errorf("invalid canUpdateBy value") + } + } + + err := p.store.UpdateCustomData(ctx, p.Game, p.Lobby, p.ID, packet.Public, packet.CustomData, packet.CanUpdateBy) + if err != nil { + logger.Error("failed to update lobby", zap.Error(err), zap.Any("customData", packet.CustomData)) + util.ReplyError(ctx, p.conn, fmt.Errorf("unable to update lobby: %v", err)) + return nil + } + + lobbyInfo, err := p.store.GetLobby(ctx, p.Game, p.Lobby) + if err != nil { + return err + } + + data, err := json.Marshal(LobbyUpdatedPacket{ + // Include the request ID for the peer that requested the update. + // Other peers will ignore this. + RequestID: packet.RequestID, + + Type: "lobbyUpdated", + LobbyInfo: lobbyInfo, + }) + if err != nil { + return err + } + return p.store.Publish(ctx, p.Game+p.Lobby, data) +} + // doLeaderElectionAndPublish will do a leader election and publish the result if a new leader was elected. // It returns true if a new leader was elected, false if not. func (p *Peer) doLeaderElectionAndPublish(ctx context.Context) (bool, error) { diff --git a/internal/signaling/stores/postgres.go b/internal/signaling/stores/postgres.go index 7ac5f00..93ea522 100644 --- a/internal/signaling/stores/postgres.go +++ b/internal/signaling/stores/postgres.go @@ -153,7 +153,7 @@ func (s *PostgresStore) Publish(ctx context.Context, topic string, data []byte) return nil } -func (s *PostgresStore) CreateLobby(ctx context.Context, game, lobbyCode, peerID string, public bool, customData map[string]any) error { +func (s *PostgresStore) CreateLobby(ctx context.Context, game, lobbyCode, peerID string, public bool, customData map[string]any, canUpdateBy string) error { if len(lobbyCode) > 20 { logger := logging.GetLogger(ctx) logger.Warn("lobby code too long", zap.String("lobbyCode", lobbyCode)) @@ -166,10 +166,10 @@ func (s *PostgresStore) CreateLobby(ctx context.Context, game, lobbyCode, peerID } now := util.NowUTC(ctx) res, err := s.DB.Exec(ctx, ` - INSERT INTO lobbies (code, game, peers, public, custom_data, created_at, updated_at, leader, term) - VALUES ($1, $2, $3, $4, $5, $6, $6, $7, 1) + INSERT INTO lobbies (code, game, peers, public, custom_data, created_at, updated_at, leader, term, can_update_by, creator) + VALUES ($1, $2, $3, $4, $5, $6, $6, $7, 1, $8, $7) ON CONFLICT DO NOTHING - `, lobbyCode, game, []string{peerID}, public, customData, now, peerID) + `, lobbyCode, game, []string{peerID}, public, customData, now, peerID, canUpdateBy) if err != nil { return err } @@ -264,11 +264,13 @@ func (s *PostgresStore) GetLobby(ctx context.Context, game, lobbyCode string) (L created_at AS "createdAt", updated_at AS "updatedAt", leader, - term + term, + can_update_by, + creator FROM lobbies WHERE code = $1 AND game = $2 - `, lobbyCode, game).Scan(&lobby.Code, &lobby.Peers, &lobby.PlayerCount, &lobby.Public, &lobby.CustomData, &lobby.CreatedAt, &lobby.UpdatedAt, &lobby.Leader, &lobby.Term) + `, lobbyCode, game).Scan(&lobby.Code, &lobby.Peers, &lobby.PlayerCount, &lobby.Public, &lobby.CustomData, &lobby.CreatedAt, &lobby.UpdatedAt, &lobby.Leader, &lobby.Term, &lobby.CanUpdateBy, &lobby.Creator) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return Lobby{}, ErrNotFound @@ -303,7 +305,9 @@ func (s *PostgresStore) ListLobbies(ctx context.Context, game, filter string) ([ created_at AS "createdAt", updated_at AS "updatedAt", leader, - term + term, + can_update_by, + creator FROM lobbies WHERE game = $1 AND public = true @@ -321,7 +325,7 @@ func (s *PostgresStore) ListLobbies(ctx context.Context, game, filter string) ([ for rows.Next() { var lobby Lobby - err = rows.Scan(&lobby.Code, &lobby.PlayerCount, &lobby.Public, &lobby.CustomData, &lobby.CreatedAt, &lobby.UpdatedAt, &lobby.Leader, &lobby.Term) + err = rows.Scan(&lobby.Code, &lobby.PlayerCount, &lobby.Public, &lobby.CustomData, &lobby.CreatedAt, &lobby.UpdatedAt, &lobby.Leader, &lobby.Term, &lobby.CanUpdateBy, &lobby.Creator) if err != nil { return nil, err } @@ -603,3 +607,72 @@ func (s *PostgresStore) DoLeaderElection(ctx context.Context, gameID, lobbyCode Term: newTerm, }, nil } + +func (s *PostgresStore) UpdateCustomData(ctx context.Context, game, lobby, peer string, public *bool, customData *map[string]any, canUpdateBy *string) error { + tx, err := s.DB.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(context.Background()) //nolint:errcheck + + var leader string + var currentCanUpdateBy string + var creator string + err = tx.QueryRow(ctx, ` + SELECT leader, can_update_by, creator + FROM lobbies + WHERE game = $1 + AND code = $2 + FOR UPDATE + `, game, lobby).Scan(&leader, ¤tCanUpdateBy, &creator) + if err != nil { + return err + } + + switch currentCanUpdateBy { + case CanUpdateByAnyone: + // No restrictions. + case CanUpdateByCreator: + if creator != peer { + return ErrNotAllowed + } + case CanUpdateByLeader: + if leader != peer { + return ErrNotAllowed + } + default: + return ErrNotAllowed + } + + columns := make([]string, 0, 3) + values := []any{game, lobby} + + if public != nil { + columns = append(columns, fmt.Sprintf("public = $%d", len(values)+1)) + values = append(values, *public) + } + if customData != nil { + columns = append(columns, fmt.Sprintf("custom_data = $%d", len(values)+1)) + values = append(values, *customData) + } + if canUpdateBy != nil { + columns = append(columns, fmt.Sprintf("can_update_by = $%d", len(values)+1)) + values = append(values, *canUpdateBy) + } + + if len(columns) == 0 { + return nil + } + + _, err = tx.Exec(ctx, ` + UPDATE lobbies + SET `+strings.Join(columns, ", ")+` + WHERE game = $1 + AND code = $2 + `, values...) + if err != nil { + return err + } + + return tx.Commit(ctx) +} diff --git a/internal/signaling/stores/shared.go b/internal/signaling/stores/shared.go index 493acf6..d674160 100644 --- a/internal/signaling/stores/shared.go +++ b/internal/signaling/stores/shared.go @@ -12,11 +12,12 @@ var ErrNotFound = errors.New("lobby not found") var ErrNoSuchTopic = errors.New("no such topic") var ErrInvalidLobbyCode = errors.New("invalid lobby code") var ErrInvalidPeerID = errors.New("invalid peer id") +var ErrNotAllowed = errors.New("not allowed") type SubscriptionCallback func(context.Context, []byte) type Store interface { - CreateLobby(ctx context.Context, game, lobby, peerID string, public bool, customData map[string]any) error + CreateLobby(ctx context.Context, game, lobby, peerID string, public bool, customData map[string]any, canUpdateBy string) error JoinLobby(ctx context.Context, game, lobby, id string) error LeaveLobby(ctx context.Context, game, lobby, id string) error GetLobby(ctx context.Context, game, lobby string) (Lobby, error) @@ -34,17 +35,28 @@ type Store interface { // DoLeaderElection attempts to elect a leader for the given lobby. If a correct leader already exists it will return nil. // If no leader can be elected, it will return an ElectionResult with a nil leader. DoLeaderElection(ctx context.Context, gameID, lobbyCode string) (*ElectionResult, error) + + UpdateCustomData(ctx context.Context, game, lobby, peer string, public *bool, customData *map[string]any, canUpdateBy *string) error } +const ( + CanUpdateByCreator = "creator" + CanUpdateByLeader = "leader" + CanUpdateByAnyone = "anyone" + CanUpdateByNone = "none" +) + type Lobby struct { Code string `json:"code"` Peers []string `json:"peers"` PlayerCount int `json:"playerCount"` + Creator string `json:"creator"` - Public bool `json:"public"` - MaxPlayers int `json:"maxPlayers"` - Password string `json:"password,omitempty"` - CustomData map[string]any `json:"customData"` + Public bool `json:"public"` + MaxPlayers int `json:"maxPlayers"` + Password string `json:"password,omitempty"` + CustomData map[string]any `json:"customData"` + CanUpdateBy string `json:"canUpdateBy"` Leader string `json:"leader,omitempty"` Term int `json:"term"` diff --git a/internal/signaling/types.go b/internal/signaling/types.go index efe0354..3f9c337 100644 --- a/internal/signaling/types.go +++ b/internal/signaling/types.go @@ -45,11 +45,12 @@ type CreatePacket struct { RequestID string `json:"rid"` Type string `json:"type"` - CodeFormat string `json:"codeFormat"` - Public bool `json:"public"` - Password string `json:"password"` - MaxPlayers int `json:"maxPlayers"` - CustomData map[string]any `json:"customData"` + CodeFormat string `json:"codeFormat"` + Public bool `json:"public"` + Password string `json:"password"` + MaxPlayers int `json:"maxPlayers"` + CustomData map[string]any `json:"customData"` + CanUpdateBy string `json:"canUpdateBy"` } type JoinPacket struct { @@ -74,6 +75,22 @@ type LeaderPacket struct { Term int `json:"term"` } +type LobbyUpdatePacket struct { + RequestID string `json:"rid"` + Type string `json:"type"` + + Public *bool `json:"public"` + CustomData *map[string]any `json:"customData"` + CanUpdateBy *string `json:"canUpdateBy"` +} + +type LobbyUpdatedPacket struct { + RequestID string `json:"rid"` + Type string `json:"type"` + + LobbyInfo stores.Lobby `json:"lobbyInfo"` +} + type ConnectPacket struct { Type string `json:"type"` diff --git a/internal/util/http.go b/internal/util/http.go index 1c8e5e3..cf2f919 100644 --- a/internal/util/http.go +++ b/internal/util/http.go @@ -17,6 +17,10 @@ import ( "nhooyr.io/websocket/wsjson" ) +type requestIDContextKeyType int + +const requestIDContextKey requestIDContextKeyType = 0 + type errorResponse struct { Status int `json:"status"` Key string `json:"key"` @@ -75,15 +79,19 @@ func ErrorAndDisconnect(ctx context.Context, conn *websocket.Conn, err error) { func ReplyError(ctx context.Context, conn *websocket.Conn, err error) { payload := struct { - Type string `json:"type"` - Message string `json:"message"` - Error any `json:"error,omitempty"` - Code string `json:"code,omitempty"` + Type string `json:"type"` + RequestID string `json:"rid,omitempty"` + Message string `json:"message"` + Error any `json:"error,omitempty"` + Code string `json:"code,omitempty"` }{ Type: "error", Message: err.Error(), Error: err, } + if rid, ok := ctx.Value(requestIDContextKey).(string); ok { + payload.RequestID = rid + } if cerr, ok := err.(interface{ ErrorCode() string }); ok { payload.Code = cerr.ErrorCode() } @@ -106,6 +114,15 @@ func RenderJSON(w http.ResponseWriter, r *http.Request, status int, val interfac } } +// WithRequestID returns a new context with the given request ID attached, this ID +// can be used when replying errors etc. +func WithRequestID(ctx context.Context, id string) context.Context { + if id == "" || len(id) > 64 { + return ctx + } + return context.WithValue(ctx, requestIDContextKey, id) +} + func IsPipeError(err error) bool { switch v := err.(type) { case syscall.Errno: diff --git a/internal/util/uuid_test.go b/internal/util/uuid_test.go index bbec0da..06af890 100644 --- a/internal/util/uuid_test.go +++ b/internal/util/uuid_test.go @@ -44,9 +44,11 @@ func FuzzIsUUID(f *testing.F) { f.Add("-") f.Add("🔥") f.Add("foobar") + var result bool f.Fuzz(func(t *testing.T, data string) { - util.IsUUID(data) + result = util.IsUUID(data) }) + _ = result } var result any diff --git a/lib/network.ts b/lib/network.ts index aac16ec..26ed6a6 100644 --- a/lib/network.ts +++ b/lib/network.ts @@ -10,6 +10,7 @@ interface NetworkListeners { ready: () => void | Promise lobby: (code: string, lobbyInfo: LobbyListEntry) => void | Promise leader: (leader: string) => void | Promise + lobbyUpdated: (code: string, settings: LobbySettings) => void | Promise connecting: (peer: Peer) => void | Promise connected: (peer: Peer) => void | Promise reconnecting: (peer: Peer) => void | Promise @@ -89,6 +90,17 @@ export default class Network extends EventEmitter { return undefined } + async setLobbySettings (settings: LobbySettings): Promise { + if (this._closing || this.signaling.receivedID === undefined) { + return new Error('network is closing or not connected') + } + await this.signaling.request({ + type: 'lobbyUpdate', + ...settings + }) + return true + } + close (reason?: string): void { if (this._closing || this.signaling.receivedID === undefined) { return @@ -188,6 +200,10 @@ export default class Network extends EventEmitter { return this.signaling.currentLobby } + get currentLobbyInfo (): LobbyListEntry | undefined { + return this.signaling.currentLobbyInfo + } + get currentLeader (): string | null | undefined { return this.signaling.currentLeader } diff --git a/lib/signaling.ts b/lib/signaling.ts index 4cf7d3f..5c80ef3 100644 --- a/lib/signaling.ts +++ b/lib/signaling.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'eventemitter3' import Network from './network' import Peer from './peer' -import { SignalingPacketTypes } from './types' +import { LobbyListEntry, SignalingPacketTypes } from './types' interface SignalingListeners { credentials: (data: SignalingPacketTypes) => void | Promise @@ -15,6 +15,7 @@ export default class Signaling extends EventEmitter { receivedID?: string receivedSecret?: string currentLobby?: string + currentLobbyInfo?: LobbyListEntry currentLeader?: string currentTerm: number = 0 @@ -215,6 +216,15 @@ export default class Signaling extends EventEmitter { } break + case 'lobbyUpdated': + if (this.currentLobby === undefined) { + // We're not in a lobby, ignore updated packets. + return + } + this.currentLobbyInfo = packet.lobbyInfo + this.network.emit('lobbyUpdated', packet.lobbyInfo.code, packet.lobbyInfo) + break + case 'connect': if (this.receivedID === packet.id) { return // Skip self diff --git a/lib/types.ts b/lib/types.ts index b23c24f..b3110c8 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -13,6 +13,7 @@ export interface LobbySettings { password?: string public?: boolean customData?: {[key: string]: any} + canEditBy?: 'creator' | 'leader' | 'anyone' | 'none' } export interface LobbyListEntry { @@ -47,6 +48,8 @@ export type SignalingPacketTypes = | JoinedPacket | JoinPacket | LeaderPacket +| LobbyUpdatePacket +| LobbyUpdatedPacket | ListPacket | LobbiesPacket | PingPacket @@ -107,6 +110,18 @@ export interface LeaderPacket extends Base { term: number } +export interface LobbyUpdatePacket extends Base { + type: 'lobbyUpdate' + public?: boolean + customData?: {[key: string]: any} + canEditBy?: 'creator' | 'leader' | 'anyone' | 'none' +} + +export interface LobbyUpdatedPacket extends Base { + type: 'lobbyUpdated' + lobbyInfo: LobbyListEntry +} + export interface ClosePacket extends Base { type: 'close' id: string diff --git a/migrations/1719251920_update_lobby.down.sql b/migrations/1719251920_update_lobby.down.sql new file mode 100644 index 0000000..b23d90e --- /dev/null +++ b/migrations/1719251920_update_lobby.down.sql @@ -0,0 +1,9 @@ +BEGIN; + +ALTER TABLE "lobbies" + DROP COLUMN "can_update_by", + DROP COLUMN "creator"; + +DROP TYPE canUpdateByEnum; + +COMMIT; diff --git a/migrations/1719251920_update_lobby.up.sql b/migrations/1719251920_update_lobby.up.sql new file mode 100644 index 0000000..8eb2a2f --- /dev/null +++ b/migrations/1719251920_update_lobby.up.sql @@ -0,0 +1,9 @@ +BEGIN; + +CREATE TYPE canUpdateByEnum AS ENUM('creator', 'leader', 'anyone', 'none'); + +ALTER TABLE "lobbies" + ADD COLUMN "can_update_by" canUpdateByEnum NOT NULL DEFAULT 'creator', + ADD COLUMN "creator" VARCHAR(20) NOT NULL DEFAULT ''; + +COMMIT; diff --git a/migrations/latest.lock b/migrations/latest.lock index 03c72b9..dd9b610 100644 --- a/migrations/latest.lock +++ b/migrations/latest.lock @@ -1 +1 @@ -1718348556_leader +1719251920_update_lobby