Skip to content

Commit

Permalink
Leader election
Browse files Browse the repository at this point in the history
Give each lobby a leader. A leader is a useful concept to have in many
game types. Implementing leader election in the game itself is much harder.

After this we can add support for the leader to be able to change the
lobby data. For example making it private when a game starts.
  • Loading branch information
erikdubbelboer committed Jun 18, 2024
1 parent 81d49b6 commit c8612a4
Show file tree
Hide file tree
Showing 15 changed files with 450 additions and 23 deletions.
8 changes: 6 additions & 2 deletions features/custom-data.feature
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ Feature: customData on lobbies can be used for filtering and extra information
"customData": {
"gameMode": "deathmatch",
"map": "de_dust2"
}
},
"leader": "h5yzwyizlwao",
"term": 1
}
]
"""
Expand All @@ -55,7 +57,9 @@ Feature: customData on lobbies can be used for filtering and extra information
"customData": {
"gameMode": "deathmatch",
"map": "de_dust2"
}
},
"leader": "h5yzwyizlwao",
"term": 1
}
]
"""
82 changes: 82 additions & 0 deletions features/leader.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
Feature: Lobbies have a leader that can control the lobby

Background:
Given the "signaling" backend is running
And the "testproxy" backend is running


Scenario: Other players see the creator as 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
And "blue" receives the network event "lobby" with the argument "prb67ouj837u"

When "yellow" connects to the lobby "prb67ouj837u"
Then "yellow" receives the network event "lobby" with the arguments:
"""json
[
"prb67ouj837u",
{
"code": "prb67ouj837u",
"peers": [
"3t3cfgcqup9e",
"h5yzwyizlwao"
],
"playerCount": 2,
"public": false,
"maxPlayers": 0,
"customData": null,
"leader": "h5yzwyizlwao",
"term": 1
}
]
"""


Scenario: A new leader is elected when the current leader disconnects
Given "blue,yellow,green" are joined in a lobby for game "4307bd86-e1df-41b8-b9df-e22afcf084bd"
And "blue" is the leader of the lobby

When "blue" disconnects
Then "yellow" becomes the leader of the lobby
And "yellow" receives the network event "leader" with the argument "3t3cfgcqup9e"
And "green" receives the network event "leader" with the argument "3t3cfgcqup9e"


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"} |

When "blue" connects to the lobby "1qva9vyurwbb"
And "blue" receives the network event "lobby" with the arguments:
"""json
[
"1qva9vyurwbb",
{
"code": "1qva9vyurwbb",
"peers": [
"h5yzwyizlwao"
],
"playerCount": 1,
"public": true,
"maxPlayers": 0,
"customData": {
"map": "de_nuke"
},
"leader": "h5yzwyizlwao",
"term": 1
}
]
"""


Scenario: A player reconnects when a websocket gets a leader event
Given "blue,yellow,green" are joined in a lobby for game "4307bd86-e1df-41b8-b9df-e22afcf084bd"

When "yellow" disconnected from the signaling server
And "blue" disconnected from the signaling server

Then "yellow" receives the network event "signalingreconnected"
And "yellow" receives the network event "leader" with the argument "ka9qy8em4vxr"
24 changes: 24 additions & 0 deletions features/support/steps/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,27 @@ When('the websocket of {string} is reconnected', function (this: World, playerNa
}
player.network._forceReconnectSignaling()
})

Given('{string} is the leader of the lobby', async function (this: World, playerName: string) {
const player = this.players.get(playerName)
if (player == null) {
throw new Error('no such player')
}
if (player.network.currentLeader !== player.network.id) {
throw new Error('player is not the leader')
}
})

Given('{string} becomes the leader of the lobby', async function (this: World, playerName: string) {
const player = this.players.get(playerName)
if (player == null) {
throw new Error('no such player')
}
const event = await player.waitForEvent('leader', [player.network.id], false)
if (event == null) {
throw new Error(`no event leader(${player.network.id}) received`)
}
if (player.network.currentLeader !== player.network.id) {
throw new Error('player is not the leader')
}
})
2 changes: 1 addition & 1 deletion features/support/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ interface RecordedEvent {
eventPayload: IArguments
}

const allEvents = ['close', 'ready', 'lobby', 'connected', 'disconnected', 'reconnecting', 'reconnected', 'message', 'signalingerror', 'signalingreconnected']
const allEvents = ['close', 'ready', 'lobby', 'connected', 'disconnected', 'reconnecting', 'reconnected', 'message', 'signalingerror', 'signalingreconnected', 'leader']

export class Player {
public lastReceivedLobbies: LobbyListEntry[] = []
Expand Down
91 changes: 83 additions & 8 deletions internal/signaling/peer.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,18 +207,70 @@ func (p *Peer) HandleHelloPacket(ctx context.Context, packet HelloPacket) error
logger.Info("peer connecting", zap.String("game", p.Game), zap.String("peer", p.ID))
}

if hasReconnected && len(reconnectingLobbies) > 0 {
p.Lobby = reconnectingLobbies[0]
p.store.Subscribe(ctx, p.ForwardMessage, p.Game, p.Lobby, p.ID)
go metrics.Record(ctx, "lobby", "reconnected", p.Game, p.ID, p.Lobby)
logger.Info("peer rejoining lobby", zap.String("game", p.Game), zap.String("peer", p.ID), zap.String("lobby", p.Lobby))
}

return p.Send(ctx, WelcomePacket{
err := p.Send(ctx, WelcomePacket{
Type: "welcome",
ID: p.ID,
Secret: p.Secret,
})
if err != nil {
return err
}

if hasReconnected {
for _, lobbyID := range reconnectingLobbies {
logger.Info("peer rejoining lobby", zap.String("game", p.Game), zap.String("peer", p.ID), zap.String("lobby", p.Lobby))
p.Lobby = lobbyID
p.store.Subscribe(ctx, p.ForwardMessage, p.Game, p.Lobby, p.ID)

go metrics.Record(ctx, "lobby", "reconnected", p.Game, p.ID, p.Lobby)

// We just reconnected, and we might be the only peer in the lobby.
// So do an election to make sure we then become the leader.
// This won't do anything if there's already a leader.
result, err := p.store.DoLeaderElection(ctx, p.Game, lobbyID)
if err != nil {
return err
} else if result != nil {
// A new leader was elected, let everyone know.
// With race conditions it's possible that there are multiple peers
// and not just us, so we need to publish the leader packet to the whole lobby.

packet := LeaderPacket{
Type: "leader",
Leader: result.Leader,
Term: result.Term,
}
data, err := json.Marshal(packet)
if err != nil {
return err
}
err = p.store.Publish(ctx, p.Game+lobbyID, data)
if err != nil {
return err
}
} else {
// No leader was elected, but we might still have missed
// changes in leadership while we were disconnected.
// So send the current leader to the client just in case.

lobbyInfo, err := p.store.GetLobby(ctx, p.Game, lobbyID)
if err != nil {
return err
}

err = p.Send(ctx, LeaderPacket{
Type: "leader",
Leader: lobbyInfo.Leader,
Term: lobbyInfo.Term,
})
if err != nil {
return err
}
}
}
}

return nil
}

func (p *Peer) HandleClosePacket(ctx context.Context, packet ClosePacket) error {
Expand Down Expand Up @@ -250,6 +302,24 @@ func (p *Peer) HandleClosePacket(ctx context.Context, packet ClosePacket) error
logger.Error("failed to publish disconnect packet", zap.Error(err))
}
}

result, err := p.store.DoLeaderElection(ctx, p.Game, p.Lobby)
if err != nil {
logger.Error("failed to do leader election", zap.Error(err))
} else if result != nil {
packet := LeaderPacket{
Type: "leader",
Leader: result.Leader,
Term: result.Term,
}
data, err := json.Marshal(packet)
if err == nil {
err = p.store.Publish(ctx, p.Game+p.Lobby, data)
if err != nil {
logger.Error("failed to publish leader packet", zap.Error(err))
}
}
}
p.Lobby = ""
}

Expand Down Expand Up @@ -348,6 +418,11 @@ func (p *Peer) HandleJoinPacket(ctx context.Context, packet JoinPacket) error {
p.Lobby = packet.Lobby
p.store.Subscribe(ctx, p.ForwardMessage, p.Game, p.Lobby, p.ID)

_, err = p.store.DoLeaderElection(ctx, p.Game, packet.Lobby)
if err != nil {
return err
}

lobby, err := p.store.GetLobby(ctx, p.Game, p.Lobby)
if err != nil {
return err
Expand Down
Loading

0 comments on commit c8612a4

Please sign in to comment.