Skip to content

Commit 4671d1f

Browse files
Bartłomiej Dachpeppy
andauthored
Add client-side support for slots in multiplayer rooms (ppy#37741)
- Part of ppy/osu-server-spectator#405 <img width="1624" height="900" alt="Screenshot 2026-05-13 at 12 40 07" src="https://github.com/user-attachments/assets/a7f36d54-4cc6-49c9-8e89-ee0d049bb637" /> <img width="1624" height="900" alt="Screenshot 2026-05-13 at 12 31 40" src="https://github.com/user-attachments/assets/0c054dfd-addd-4d00-bbca-119b4c3ec3cb" /> Will not work until relevant server-side support is in. --- I was in two minds whether to PR this all at once or to PR only ppy@693e4ef to begin with to unblock server-side implementation. In the end I opted for one PR because usage informs the model, so I find everything else relevant as part of review of the model design. If there are concerns about this making it into a release without server-side support and therefore things looking broken I will split the commit out on request. I put in some effort to add relevant logic in test multiplayer client to simulate the server side but I may well have missed something. --------- Co-authored-by: Dean Herbert <pe@ppy.sh>
1 parent f6cd5f8 commit 4671d1f

22 files changed

Lines changed: 431 additions & 104 deletions

osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1206,6 +1206,27 @@ public void TestUserStyleSelectionExitedWhenBeatmapSetChanged()
12061206
AddUntilStep("style selection screen closed", () => this.ChildrenOfType<MultiplayerMatchFreestyleSelect>().SingleOrDefault()?.IsCurrentScreen() != true);
12071207
}
12081208

1209+
[Test]
1210+
public void TestMaxParticipantsAndSlots()
1211+
{
1212+
createRoom(() => new Room
1213+
{
1214+
Name = "Test Room",
1215+
Password = "password",
1216+
Playlist =
1217+
[
1218+
new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo)
1219+
{
1220+
RulesetID = new OsuRuleset().RulesetInfo.OnlineID
1221+
}
1222+
],
1223+
MaxParticipants = 10
1224+
});
1225+
1226+
AddStep("turn max participants off", () => multiplayerClient.ChangeSettings(maxParticipants: null));
1227+
AddStep("turn max participants back on", () => multiplayerClient.ChangeSettings(maxParticipants: 8));
1228+
}
1229+
12091230
private void enterGameplay()
12101231
{
12111232
pressReadyButton();

osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Linq;
66
using System.Threading;
77
using NUnit.Framework;
8+
using osu.Framework.Extensions;
89
using osu.Framework.Extensions.ObjectExtensions;
910
using osu.Framework.Graphics;
1011
using osu.Framework.Graphics.Sprites;
@@ -55,6 +56,68 @@ public void TestAddUser()
5556
AddAssert("two unique panels", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 2);
5657
}
5758

59+
[Test]
60+
public void TestSlots()
61+
{
62+
setUpList();
63+
AddAssert("one unique panel", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 1);
64+
65+
AddStep("add user", () => MultiplayerClient.AddUser(new APIUser
66+
{
67+
Id = 3,
68+
Username = "Second",
69+
CoverUrl = TestResources.COVER_IMAGE_3,
70+
}));
71+
72+
AddAssert("two unique panels", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 2);
73+
74+
AddStep("introduce slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState
75+
{
76+
Slots = [null, 3, null, null, 1001, null, null]
77+
}).WaitSafely());
78+
79+
AddStep("click first slot", () =>
80+
{
81+
InputManager.MoveMouseTo(this.ChildrenOfType<ParticipantPanel>().First());
82+
InputManager.Click(MouseButton.Left);
83+
});
84+
AddUntilStep("slots changed", () => ((StandardMatchRoomState)MultiplayerClient.ClientRoom!.MatchState!).Slots,
85+
() => Is.EquivalentTo(new int?[] { 1001, 3, null, null, null, null, null }));
86+
87+
AddStep("click second slot", () =>
88+
{
89+
InputManager.MoveMouseTo(this.ChildrenOfType<ParticipantPanel>().ElementAt(1));
90+
InputManager.Click(MouseButton.Left);
91+
});
92+
AddUntilStep("slots not changed", () => ((StandardMatchRoomState)MultiplayerClient.ClientRoom!.MatchState!).Slots,
93+
() => Is.EquivalentTo(new int?[] { 1001, 3, null, null, null, null, null }));
94+
95+
AddStep("click last slot", () =>
96+
{
97+
InputManager.MoveMouseTo(this.ChildrenOfType<ParticipantPanel>().Last());
98+
InputManager.Click(MouseButton.Left);
99+
});
100+
AddUntilStep("slots changed", () => ((StandardMatchRoomState)MultiplayerClient.ClientRoom!.MatchState!).Slots,
101+
() => Is.EquivalentTo(new int?[] { null, 3, null, null, null, null, 1001 }));
102+
103+
AddStep("shuffle slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState
104+
{
105+
Slots = [null, null, 1001, null, null, null, 3]
106+
}).WaitSafely());
107+
AddStep("remove slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState
108+
{
109+
Slots = [null, 3, null, 1001]
110+
}).WaitSafely());
111+
AddStep("add slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState
112+
{
113+
Slots = [null, null, 3, null, 1001, null]
114+
}).WaitSafely());
115+
AddStep("turn off slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState
116+
{
117+
Slots = null
118+
}).WaitSafely());
119+
}
120+
58121
[Test]
59122
public void TestAddReferee()
60123
{
@@ -86,7 +149,7 @@ public void TestAddUnresolvedUser()
86149

87150
AddUntilStep("two unique panels", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 2);
88151

89-
AddStep("kick null user", () => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.User == null)
152+
AddStep("kick null user", () => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.User?.User == null)
90153
.ChildrenOfType<ParticipantPanel.KickButton>().Single().TriggerClick());
91154

92155
AddUntilStep("null user kicked", () => MultiplayerClient.ClientRoom.AsNonNull().Users.Count == 1);
@@ -111,7 +174,7 @@ public void TestRemoveUser()
111174

112175
AddStep("remove host", () => MultiplayerClient.RemoveUser(API.LocalUser.Value));
113176

114-
AddAssert("single panel is for second user", () => this.ChildrenOfType<ParticipantPanel>().Single().Current.Value.UserID == secondUser?.Id);
177+
AddAssert("single panel is for second user", () => this.ChildrenOfType<ParticipantPanel>().Single().Current.Value.User?.UserID == secondUser?.Id);
115178
}
116179

117180
[Test]
@@ -150,7 +213,7 @@ public void TestBeatmapDownloadingStates()
150213

151214
AddRepeatStep("increment progress", () =>
152215
{
153-
float progress = this.ChildrenOfType<ParticipantPanel>().Single().Current.Value.BeatmapAvailability.DownloadProgress ?? 0;
216+
float progress = this.ChildrenOfType<ParticipantPanel>().Single().Current.Value.User?.BeatmapAvailability.DownloadProgress ?? 0;
154217
MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(progress + RNG.NextSingle(0.1f)));
155218
}, 25);
156219

@@ -195,16 +258,16 @@ public void TestCrownChangesStateWhenHostTransferred()
195258
}));
196259

197260
AddUntilStep("first user crown visible",
198-
() => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.UserID == 1001).ChildrenOfType<SpriteIcon>().First().Alpha == 1);
261+
() => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.User?.UserID == 1001).ChildrenOfType<SpriteIcon>().First().Alpha == 1);
199262
AddUntilStep("second user crown hidden",
200-
() => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.UserID == 3).ChildrenOfType<SpriteIcon>().First().Alpha == 0);
263+
() => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.User?.UserID == 3).ChildrenOfType<SpriteIcon>().First().Alpha == 0);
201264

202265
AddStep("make second user host", () => MultiplayerClient.TransferHost(3));
203266

204267
AddUntilStep("first user crown visible",
205-
() => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.UserID == 1001).ChildrenOfType<SpriteIcon>().First().Alpha == 0);
268+
() => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.User?.UserID == 1001).ChildrenOfType<SpriteIcon>().First().Alpha == 0);
206269
AddUntilStep("second user crown hidden",
207-
() => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.UserID == 3).ChildrenOfType<SpriteIcon>().First().Alpha == 1);
270+
() => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.User?.UserID == 3).ChildrenOfType<SpriteIcon>().First().Alpha == 1);
208271
}
209272

210273
[Test]
@@ -221,8 +284,8 @@ public void TestHostGetsPinnedToTop()
221284
AddStep("make second user host", () => MultiplayerClient.TransferHost(3));
222285
AddAssert("second user above first", () =>
223286
{
224-
var first = this.ChildrenOfType<ParticipantPanel>().Single(u => u.Current.Value.UserID == 1001);
225-
var second = this.ChildrenOfType<ParticipantPanel>().Single(u => u.Current.Value.UserID == 3);
287+
var first = this.ChildrenOfType<ParticipantPanel>().Single(u => u.Current.Value.User?.UserID == 1001);
288+
var second = this.ChildrenOfType<ParticipantPanel>().Single(u => u.Current.Value.User?.UserID == 3);
226289
return second.ScreenSpaceDrawQuad.TopLeft.Y < first.ScreenSpaceDrawQuad.TopLeft.Y;
227290
});
228291
}

osu.Game.Tests/Visual/Multiplayer/TestSceneRoomPanel.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,23 @@ public void TestEnableAndDisablePassword()
178178
AddAssert("password icon hidden", () => Precision.AlmostEquals(0, panel.ChildrenOfType<RoomPanel.CornerIcon>().First().Alpha));
179179
}
180180

181+
[Test]
182+
public void TestSetAndUnsetMaxParticipants()
183+
{
184+
RoomPanel panel = null!;
185+
Room room = null!;
186+
187+
AddStep("create room", () => Child = panel = createLoungeRoom(room = new Room
188+
{
189+
Name = "A room",
190+
Type = MatchType.HeadToHead,
191+
}));
192+
193+
AddUntilStep("wait for panel load", () => panel.ChildrenOfType<DrawableRoomParticipantsList>().Any());
194+
AddStep("set max participants", () => room.MaxParticipants = 5);
195+
AddStep("unset max participants", () => room.MaxParticipants = null);
196+
}
197+
181198
[Test]
182199
public void TestMultiplayerRooms()
183200
{
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2+
// See the LICENCE file in the repository root for full licence text.
3+
4+
using MessagePack;
5+
6+
namespace osu.Game.Online.Multiplayer
7+
{
8+
/// <summary>
9+
/// User requests to change their slot in the room.
10+
/// </summary>
11+
[MessagePackObject]
12+
public class ChangeSlotRequest : MatchUserRequest
13+
{
14+
/// <summary>
15+
/// The zero-based ID of the desired slot.
16+
/// </summary>
17+
[Key(0)]
18+
public byte SlotID { get; set; }
19+
}
20+
}

osu.Game/Online/Multiplayer/MatchRoomState.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ namespace osu.Game.Online.Multiplayer
1818
[Union(0, typeof(TeamVersusRoomState))] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types.
1919
[Union(1, typeof(MatchmakingRoomState))]
2020
[Union(2, typeof(RankedPlayRoomState))]
21+
[Union(3, typeof(StandardMatchRoomState))]
2122
public abstract class MatchRoomState
2223
{
2324
}

osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,20 @@
77
namespace osu.Game.Online.Multiplayer.MatchTypes.TeamVersus
88
{
99
[MessagePackObject]
10-
public class TeamVersusRoomState : MatchRoomState
10+
public class TeamVersusRoomState : StandardMatchRoomState
1111
{
1212
[Key(0)]
1313
public List<MultiplayerTeam> Teams { get; set; } = new List<MultiplayerTeam>();
1414

15-
[Key(1)]
16-
public bool Locked { get; set; }
17-
18-
public static TeamVersusRoomState CreateDefault() =>
15+
public static TeamVersusRoomState CreateDefault(byte? maxParticipants = null) =>
1916
new TeamVersusRoomState
2017
{
2118
Teams =
2219
{
2320
new MultiplayerTeam { ID = 0, Name = "Team Red" },
2421
new MultiplayerTeam { ID = 1, Name = "Team Blue" },
25-
}
22+
},
23+
Slots = maxParticipants == null ? null : new int?[maxParticipants.Value]
2624
};
2725
}
2826
}

osu.Game/Online/Multiplayer/MatchUserRequest.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ namespace osu.Game.Online.Multiplayer
2323
[Union(4, typeof(RankedPlayCardHandReplayRequest))]
2424
[Union(5, typeof(SetLockStateRequest))]
2525
[Union(6, typeof(RollRequest))]
26+
[Union(7, typeof(ChangeSlotRequest))]
2627
public abstract class MatchUserRequest
2728
{
2829
}

osu.Game/Online/Multiplayer/MultiplayerClient.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,8 +403,9 @@ await runOnUpdateThreadAsync(() =>
403403
/// <param name="queueMode">The new queue mode, if any.</param>
404404
/// <param name="autoStartDuration">The new auto-start countdown duration, if any.</param>
405405
/// <param name="autoSkip">The new auto-skip setting.</param>
406+
/// <param name="maxParticipants">The new participant count limit, if any.</param>
406407
public Task ChangeSettings(Optional<string> name = default, Optional<string> password = default, Optional<MatchType> matchType = default, Optional<QueueMode> queueMode = default,
407-
Optional<TimeSpan> autoStartDuration = default, Optional<bool> autoSkip = default)
408+
Optional<TimeSpan> autoStartDuration = default, Optional<bool> autoSkip = default, Optional<byte?> maxParticipants = default)
408409
{
409410
if (Room == null)
410411
throw new InvalidOperationException("Must be joined to a match to change settings.");
@@ -416,7 +417,8 @@ public Task ChangeSettings(Optional<string> name = default, Optional<string> pas
416417
MatchType = matchType.GetOr(Room.Settings.MatchType),
417418
QueueMode = queueMode.GetOr(Room.Settings.QueueMode),
418419
AutoStartDuration = autoStartDuration.GetOr(Room.Settings.AutoStartDuration),
419-
AutoSkip = autoSkip.GetOr(Room.Settings.AutoSkip)
420+
AutoSkip = autoSkip.GetOr(Room.Settings.AutoSkip),
421+
MaxParticipants = maxParticipants.GetOr(Room.Settings.MaxParticipants),
420422
});
421423
}
422424

osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ public class MultiplayerRoomSettings : IEquatable<MultiplayerRoomSettings>
3232
[Key(6)]
3333
public bool AutoSkip { get; set; }
3434

35+
[Key(7)]
36+
public byte? MaxParticipants { get; set; }
37+
3538
[IgnoreMember]
3639
public bool AutoStartEnabled => AutoStartDuration != TimeSpan.Zero;
3740

@@ -47,6 +50,7 @@ public MultiplayerRoomSettings(Room room)
4750
QueueMode = room.QueueMode;
4851
AutoStartDuration = room.AutoStartDuration;
4952
AutoSkip = room.AutoSkip;
53+
MaxParticipants = room.MaxParticipants;
5054
}
5155

5256
public bool Equals(MultiplayerRoomSettings? other)
@@ -60,7 +64,8 @@ public bool Equals(MultiplayerRoomSettings? other)
6064
&& MatchType == other.MatchType
6165
&& QueueMode == other.QueueMode
6266
&& AutoStartDuration == other.AutoStartDuration
63-
&& AutoSkip == other.AutoSkip;
67+
&& AutoSkip == other.AutoSkip
68+
&& MaxParticipants == other.MaxParticipants;
6469
}
6570

6671
public override string ToString() => $"Name:{Name}"
@@ -69,6 +74,7 @@ public override string ToString() => $"Name:{Name}"
6974
+ $" Item:{PlaylistItemId}"
7075
+ $" Queue:{QueueMode}"
7176
+ $" Start:{AutoStartDuration}"
72-
+ $" AutoSkip:{AutoSkip}";
77+
+ $" AutoSkip:{AutoSkip}"
78+
+ $" MaxParticipants:{MaxParticipants?.ToString() ?? "no limit"}";
7379
}
7480
}

osu.Game/Online/Multiplayer/SetLockStateRequest.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,13 @@ public class SetLockStateRequest : MatchUserRequest
1010
{
1111
/// <summary>
1212
/// <para>
13-
/// If <see langword="true"/>, <see cref="MultiplayerRoomUserRole.Player"/>s will not be able to change teams by themselves in the room,
13+
/// If <see langword="true"/>, <see cref="MultiplayerRoomUserRole.Player"/>s will not be able to change teams and slots by themselves in the room,
1414
/// only <see cref="MultiplayerRoomUserRole.Referee"/>s will be able to change teams for the <see cref="MultiplayerRoomUserRole.Player"/>s.
1515
/// </para>
1616
/// <para>
17-
/// If <see langword="false"/>, any user can change their team in the room.
17+
/// If <see langword="false"/>, any user can change their team and slot in the room.
1818
/// </para>
1919
/// </summary>
20-
// TODO: mention slots as well when slots are reimplemented
2120
[Key(0)]
2221
public bool Locked { get; set; }
2322
}

0 commit comments

Comments
 (0)