主机创建房间,可进入游戏

This commit is contained in:
LA
2026-02-20 15:58:32 +08:00
parent 23b2f5dedd
commit 59b9faac61
21 changed files with 1503 additions and 129 deletions

View File

@@ -33,6 +33,7 @@ using osu.Game.Online.Chat;
using osu.Game.Online.Discovery;
using osu.Game.Online.LocalMultiplayer;
using osu.Game.Online.Notifications.WebSocket;
using osu.Game.Beatmaps;
namespace osu.Game.Online.API
{
@@ -41,6 +42,17 @@ namespace osu.Game.Online.API
// Local multiplayer server used in local-only mode.
private LocalMultiplayerServer localServer;
private LocalMultiplayerDiscovery localDiscovery;
private LocalMultiplayerDirectServer localDirectServer;
private readonly Dictionary<long, RemoteRoomReference> remoteDiscoveredRooms = new Dictionary<long, RemoteRoomReference>();
private class RemoteRoomReference
{
public long RemoteRoomId;
public IPEndPoint Endpoint;
public DateTimeOffset LastSeen;
}
private static readonly TimeSpan remote_room_reference_ttl = TimeSpan.FromMinutes(2);
private readonly OsuGameBase game;
private readonly OsuConfigManager config;
@@ -125,14 +137,9 @@ namespace osu.Game.Online.API
state.Value = APIState.Connecting;
}
// If the experimental P2P flag is enabled in Ez2 config, treat this client as local-only
// (i.e. act as its own server) to avoid server-side checks interfering with P2P usage.
try
{
if (GlobalConfigStore.EzConfig.Get<bool>(Ez2Setting.ExperimentalP2P))
LoginLocal(string.IsNullOrEmpty(ProvidedUsername) ? "Local" : ProvidedUsername);
}
catch { }
// If the experimental P2P flag is enabled, force this API into local-only mode.
if (isP2PForced())
LoginLocal(string.IsNullOrEmpty(ProvidedUsername) ? "Local" : ProvidedUsername);
var thread = new Thread(run)
{
@@ -438,9 +445,22 @@ namespace osu.Game.Online.API
switch (request)
{
case GetRoomsRequest getRooms:
pruneExpiredRemoteReferences();
getRooms.TriggerSuccess(localServer.GetRooms().ToList());
return true;
case ListChannelsRequest listChannelsReq:
listChannelsReq.TriggerSuccess(localServer.GetChannels().ToList());
return true;
case GetMessagesRequest getMessagesReq:
getMessagesReq.TriggerSuccess(localServer.GetChannelMessages(getMessagesReq.Channel.Id).ToList());
return true;
case PostMessageRequest postMessageReq:
postMessageReq.TriggerSuccess(localServer.PostChannelMessage(postMessageReq.Message, LocalUser.Value));
return true;
case CreateRoomRequest createReq:
{
var created = localServer.CreateRoom(createReq.Room, LocalUser.Value);
@@ -454,6 +474,7 @@ namespace osu.Game.Online.API
RoomID = created.RoomID.HasValue ? (int)created.RoomID.Value : 0,
HostName = LocalUser.Value?.Username ?? ProvidedUsername,
IsP2P = createReq.Room.IsP2P,
ControlPort = LocalMultiplayerDirectServer.DEFAULT_PORT,
Timestamp = DateTimeOffset.Now,
});
}
@@ -466,6 +487,12 @@ namespace osu.Game.Online.API
{
var r = localServer.GetRoom(getRoomReq.RoomId);
if (r == null && tryGetRemoteRoom(getRoomReq.RoomId, out Room remoteRoom, out _))
{
localServer.UpsertRoom(remoteRoom);
r = remoteRoom;
}
if (r == null)
{
getRoomReq.TriggerFailure(new InvalidOperationException("Room not found."));
@@ -476,13 +503,110 @@ namespace osu.Game.Online.API
return true;
}
case GetUsersRequest getUsersReq:
{
APIUser local = LocalUser.Value;
getUsersReq.TriggerSuccess(new GetUsersResponse
{
Users = getUsersReq.UserIds
.Distinct()
.Select(id => local != null && local.Id == id
? local
: new APIUser
{
Id = id,
Username = $"User {id}",
})
.ToList()
});
return true;
}
case LookupUsersRequest lookupUsersReq:
{
APIUser local = LocalUser.Value;
lookupUsersReq.TriggerSuccess(new GetUsersResponse
{
Users = lookupUsersReq.UserIds
.Distinct()
.Select(id => local != null && local.Id == id
? local
: new APIUser
{
Id = id,
Username = $"User {id}",
})
.ToList()
});
return true;
}
case GetBeatmapsRequest getBeatmapsReq:
{
List<APIBeatmap> beatmaps = getBeatmapsReq.BeatmapIds
.Distinct()
.Select(id => tryGetKnownBeatmapInfo(id, out IBeatmapInfo info)
? createApiBeatmap(info)
: createFallbackApiBeatmap(id))
.ToList();
getBeatmapsReq.TriggerSuccess(new GetBeatmapsResponse
{
Beatmaps = beatmaps
});
return true;
}
case GetBeatmapSetRequest getBeatmapSetReq:
{
IBeatmapInfo sourceBeatmap;
if (getBeatmapSetReq.Type == BeatmapSetLookupType.BeatmapId)
{
if (!tryGetKnownBeatmapInfo(getBeatmapSetReq.ID, out sourceBeatmap))
sourceBeatmap = null;
}
else
{
sourceBeatmap = localServer.GetRooms()
.SelectMany(r => r.Playlist)
.Select(p => p.Beatmap)
.FirstOrDefault(b => b.BeatmapSet?.OnlineID == getBeatmapSetReq.ID);
}
getBeatmapSetReq.TriggerSuccess(sourceBeatmap != null
? createApiBeatmapSet(sourceBeatmap)
: createFallbackApiBeatmapSet(getBeatmapSetReq.ID));
return true;
}
case JoinRoomRequest joinReq:
{
string remoteJoinError = null;
if (joinReq.Room.RoomID != null
&& tryGetRemoteRoomReference(joinReq.Room.RoomID.Value, out RemoteRoomReference remoteRef)
&& LocalMultiplayerDirectClient.TryJoinRoom(remoteRef.Endpoint, remoteRef.RemoteRoomId, joinReq.Password, LocalUser.Value, out Room remoteJoined, out remoteJoinError))
{
// keep synthetic room id locally while preserving remote id in ChannelId for follow-up calls.
remoteJoined.ChannelId = (int)remoteRef.RemoteRoomId;
remoteJoined.RoomID = joinReq.Room.RoomID;
localServer.UpsertRoom(remoteJoined);
joinReq.TriggerSuccess(remoteJoined);
return true;
}
var (success, room, error) = localServer.JoinRoom(joinReq.Room, LocalUser.Value, joinReq.Password);
if (!success)
{
joinReq.TriggerFailure(new InvalidOperationException(error));
joinReq.TriggerFailure(new InvalidOperationException(error ?? remoteJoinError ?? "Join failed."));
return true;
}
@@ -491,9 +615,19 @@ namespace osu.Game.Online.API
}
case PartRoomRequest partReq:
localServer.PartRoom(partReqRoom(partReq), LocalUser.Value);
{
Room partRoom = partReqRoom(partReq);
if (partRoom.RoomID != null
&& tryGetRemoteRoomReference(partRoom.RoomID.Value, out RemoteRoomReference remoteRef))
{
LocalMultiplayerDirectClient.TryPartRoom(remoteRef.Endpoint, remoteRef.RemoteRoomId, LocalUser.Value, out _);
}
localServer.PartRoom(partRoom, LocalUser.Value);
partReq.TriggerSuccess();
return true;
}
case CreateRoomScoreRequest createScoreReq:
{
@@ -524,6 +658,10 @@ namespace osu.Game.Online.API
return true;
}
case ChatAckRequest ackReq:
ackReq.TriggerSuccess(new ChatAckResponse());
return true;
default:
return false;
}
@@ -555,6 +693,99 @@ namespace osu.Game.Online.API
return (T)field.GetValue(obj)!;
}
private bool tryGetKnownBeatmapInfo(int beatmapId, out IBeatmapInfo beatmapInfo)
{
beatmapInfo = localServer.GetRooms()
.SelectMany(r => r.Playlist)
.Select(p => p.Beatmap)
.FirstOrDefault(b => b.OnlineID == beatmapId);
return beatmapInfo != null;
}
private APIBeatmap createApiBeatmap(IBeatmapInfo source)
{
int setId = source.BeatmapSet?.OnlineID ?? source.OnlineID;
APIBeatmapSet set = createApiBeatmapSet(source);
return new APIBeatmap
{
OnlineID = source.OnlineID,
OnlineBeatmapSetID = setId,
Status = source is BeatmapInfo beatmapInfo ? beatmapInfo.Status : BeatmapOnlineStatus.Ranked,
Checksum = source.MD5Hash,
AuthorID = source.Metadata.Author.OnlineID,
RulesetID = source.Ruleset.OnlineID,
StarRating = source.StarRating,
DifficultyName = source.DifficultyName,
BeatmapSet = set,
};
}
private APIBeatmapSet createApiBeatmapSet(IBeatmapInfo source)
{
int setId = source.BeatmapSet?.OnlineID ?? source.OnlineID;
BeatmapSetOnlineCovers covers = createCovers(setId);
return new APIBeatmapSet
{
OnlineID = setId,
Status = BeatmapOnlineStatus.Ranked,
Covers = covers,
Title = source.Metadata.Title,
TitleUnicode = source.Metadata.TitleUnicode,
Artist = source.Metadata.Artist,
ArtistUnicode = source.Metadata.ArtistUnicode,
Author = new APIUser
{
Id = source.Metadata.Author.OnlineID,
Username = source.Metadata.Author.Username
},
Beatmaps = Array.Empty<APIBeatmap>(),
};
}
private APIBeatmap createFallbackApiBeatmap(int beatmapId)
{
APIBeatmapSet set = createFallbackApiBeatmapSet(beatmapId);
return new APIBeatmap
{
OnlineID = beatmapId,
OnlineBeatmapSetID = set.OnlineID,
Status = BeatmapOnlineStatus.Ranked,
BeatmapSet = set,
};
}
private APIBeatmapSet createFallbackApiBeatmapSet(int setId) => new APIBeatmapSet
{
OnlineID = setId,
Status = BeatmapOnlineStatus.Ranked,
Covers = createCovers(setId),
Beatmaps = Array.Empty<APIBeatmap>(),
};
private BeatmapSetOnlineCovers createCovers(int setId)
{
if (setId <= 0)
return default;
string baseUrl = $"https://assets.ppy.sh/beatmaps/{setId}/covers";
return new BeatmapSetOnlineCovers
{
Cover = $"{baseUrl}/cover.jpg",
CoverLowRes = $"{baseUrl}/cover.jpg",
Card = $"{baseUrl}/card.jpg",
CardLowRes = $"{baseUrl}/card.jpg",
List = $"{baseUrl}/list.jpg",
ListLowRes = $"{baseUrl}/list.jpg",
};
}
public Task PerformAsync(APIRequest request) =>
Task.Factory.StartNew(() => Perform(request), TaskCreationOptions.LongRunning);
@@ -562,6 +793,12 @@ namespace osu.Game.Online.API
{
Debug.Assert(State.Value == APIState.Offline);
if (isP2PForced())
{
LoginLocal(string.IsNullOrEmpty(username) ? "Local" : username);
return;
}
ProvidedUsername = username;
this.password = password;
IsLocalOnly = false;
@@ -584,6 +821,7 @@ namespace osu.Game.Online.API
try
{
localServer ??= new LocalMultiplayerServer();
localDirectServer ??= new LocalMultiplayerDirectServer(localServer);
if (localDiscovery == null)
{
@@ -604,15 +842,32 @@ namespace osu.Game.Online.API
{
try
{
long syntheticId = -generateRoomKey(discovered.AdvertiserEndpoint, discovered.RoomID);
int controlPort = discovered.ControlPort > 0 ? discovered.ControlPort : LocalMultiplayerDirectServer.DEFAULT_PORT;
IPEndPoint endpoint = discovered.AdvertiserEndpoint == null
? null
: new IPEndPoint(discovered.AdvertiserEndpoint.Address, controlPort);
if (endpoint != null)
{
remoteDiscoveredRooms[syntheticId] = new RemoteRoomReference
{
RemoteRoomId = discovered.RoomID,
Endpoint = endpoint,
LastSeen = DateTimeOffset.UtcNow,
};
}
// create a synthetic room representation for discovered remote host
var room = new Room
{
RoomID = -generateRoomKeyFromEndpoint(discovered.AdvertiserEndpoint),
RoomID = syntheticId,
Name = discovered.Name,
Host = new APIUser { Username = discovered.HostName },
IsP2P = discovered.IsP2P,
StartDate = discovered.Timestamp,
EndDate = discovered.Timestamp.AddHours(2),
EndDate = discovered.Timestamp.Add(remote_room_reference_ttl),
ParticipantCount = 1,
};
@@ -626,7 +881,7 @@ namespace osu.Game.Online.API
});
}
private int generateRoomKeyFromEndpoint(IPEndPoint ep)
private int generateRoomKey(IPEndPoint ep, int remoteRoomId)
{
if (ep == null) return (int)DateTimeOffset.Now.ToUnixTimeSeconds();
@@ -635,6 +890,7 @@ namespace osu.Game.Online.API
int hash = 17;
hash = hash * 23 + ep.Address.GetHashCode();
hash = hash * 23 + ep.Port;
hash = hash * 23 + remoteRoomId;
return Math.Abs(hash);
}
}
@@ -810,6 +1066,10 @@ namespace osu.Game.Online.API
{
lock (queue)
{
// P2P experiments run only through local/direct path (no online API dependency).
if (isP2PForced())
ensureLocalModeStarted();
// If a user attempts to create an experimental P2P room while we don't have
// a valid authenticated online session, switch to local-only mode so the
// request can be handled locally instead of being sent unauthenticated.
@@ -860,6 +1120,9 @@ namespace osu.Game.Online.API
}
}
private static bool isP2PForced()
=> GlobalConfigStore.EzConfig.Get<bool>(Ez2Setting.ExperimentalP2P);
private void ensureLocalModeStarted()
{
if (IsLocalOnly)
@@ -885,6 +1148,7 @@ namespace osu.Game.Online.API
try
{
localServer ??= new LocalMultiplayerServer();
localDirectServer ??= new LocalMultiplayerDirectServer(localServer);
if (localDiscovery == null)
{
@@ -915,12 +1179,126 @@ namespace osu.Game.Online.API
}
}
private bool tryGetRemoteRoomReference(long localSyntheticRoomId, out RemoteRoomReference remote)
{
pruneExpiredRemoteReferences();
if (!remoteDiscoveredRooms.TryGetValue(localSyntheticRoomId, out remote))
return false;
remote.LastSeen = DateTimeOffset.UtcNow;
return true;
}
private bool tryGetRemoteRoom(long localSyntheticRoomId, out Room room, out string error)
{
room = null;
error = null;
if (!tryGetRemoteRoomReference(localSyntheticRoomId, out RemoteRoomReference remote))
return false;
if (!LocalMultiplayerDirectClient.TryGetRoom(remote.Endpoint, remote.RemoteRoomId, out Room fetched, out error) || fetched == null)
return false;
fetched.ChannelId = (int)remote.RemoteRoomId;
fetched.RoomID = localSyntheticRoomId;
room = fetched;
return true;
}
public bool TryDiscoverRemoteHost(string addressOrEndpoint, out string error, out int discoveredCount)
{
error = null;
discoveredCount = 0;
if (string.IsNullOrWhiteSpace(addressOrEndpoint))
{
error = "Address is empty.";
return false;
}
ensureLocalModeStarted();
string address = addressOrEndpoint.Trim();
int port = LocalMultiplayerDirectServer.DEFAULT_PORT;
int idx = address.LastIndexOf(':');
if (idx > 0 && idx < address.Length - 1 && int.TryParse(address[(idx + 1)..], out int parsedPort))
{
port = parsedPort;
address = address[..idx];
}
if (!IPAddress.TryParse(address, out IPAddress ip))
{
error = "Invalid IP address format. Expected x.x.x.x[:port].";
return false;
}
var endpoint = new IPEndPoint(ip, port);
if (!LocalMultiplayerDirectClient.TryListRooms(endpoint, out Room[] rooms, out error))
return false;
foreach (Room remoteRoom in rooms ?? Array.Empty<Room>())
{
if (remoteRoom?.RoomID == null)
continue;
long syntheticId = -generateRoomKey(endpoint, (int)remoteRoom.RoomID.Value);
remoteDiscoveredRooms[syntheticId] = new RemoteRoomReference
{
RemoteRoomId = remoteRoom.RoomID.Value,
Endpoint = endpoint,
LastSeen = DateTimeOffset.UtcNow,
};
remoteRoom.ChannelId = (int)remoteRoom.RoomID.Value;
remoteRoom.RoomID = syntheticId;
localServer.UpsertRoom(remoteRoom);
discoveredCount++;
}
return true;
}
private void pruneExpiredRemoteReferences()
{
if (remoteDiscoveredRooms.Count == 0)
return;
DateTimeOffset now = DateTimeOffset.UtcNow;
foreach (long roomId in remoteDiscoveredRooms.Where(kv => now - kv.Value.LastSeen > remote_room_reference_ttl).Select(kv => kv.Key).ToArray())
remoteDiscoveredRooms.Remove(roomId);
localServer?.CleanupExpired();
}
public void Logout()
{
password = null;
SecondFactorCode = null;
authentication.Clear();
remoteDiscoveredRooms.Clear();
if (localDiscovery != null)
{
localDiscovery.Dispose();
localDiscovery = null;
}
if (localDirectServer != null)
{
localDirectServer.Dispose();
localDirectServer = null;
}
localUserState.ClearLocalUser();
IsLocalOnly = false;
@@ -932,6 +1310,18 @@ namespace osu.Game.Online.API
{
base.Dispose(isDisposing);
try
{
localDiscovery?.Dispose();
}
catch { }
try
{
localDirectServer?.Dispose();
}
catch { }
flushQueue();
cancellationToken.Cancel();
}

View File

@@ -9,7 +9,10 @@ using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.IO.Network;
using osu.Framework.Logging;
using osu.Game.Extensions;
using osu.Game.LAsEzExtensions.Configuration;
using osu.Game.Online.Rooms;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.API.Requests;
namespace osu.Game.Online.API
{
@@ -98,7 +101,25 @@ namespace osu.Game.Online.API
/// Whether this request is permitted to run when the <see cref="APIAccess"/> is in local-only mode.
/// By default requests are not allowed and will be failed early when running under a local-only login.
/// </summary>
public virtual bool AllowLocal => false;
public virtual bool AllowLocal
=> GlobalConfigStore.EzConfig.Get<bool>(Ez2Setting.ExperimentalP2P)
&& (this is GetRoomsRequest
|| this is CreateRoomRequest
|| this is GetRoomRequest
|| this is JoinRoomRequest
|| this is PartRoomRequest
|| this is ListChannelsRequest
|| this is GetMessagesRequest
|| this is PostMessageRequest
|| this is CreateRoomScoreRequest
|| this is GetRoomLeaderboardRequest
|| this is IndexPlaylistScoresRequest
|| this is SubmitRoomScoreRequest
|| this is ChatAckRequest
|| this is GetUsersRequest
|| this is LookupUsersRequest
|| this is GetBeatmapsRequest
|| this is GetBeatmapSetRequest);
private readonly object completionStateLock = new object();

View File

@@ -18,8 +18,8 @@ namespace osu.Game.Online.Discovery
/// </summary>
public class LocalMultiplayerDiscovery : IDisposable
{
private const string multicastAddress = "239.0.0.222";
private const int multicastPort = 5325;
private const string multicast_address = "239.0.0.222";
private const int multicast_port = 5325;
private readonly UdpClient listener;
private readonly UdpClient broadcaster;
@@ -30,13 +30,13 @@ namespace osu.Game.Online.Discovery
public LocalMultiplayerDiscovery()
{
groupEndpoint = new IPEndPoint(IPAddress.Parse(multicastAddress), multicastPort);
groupEndpoint = new IPEndPoint(IPAddress.Parse(multicast_address), multicast_port);
listener = new UdpClient(AddressFamily.InterNetwork);
listener.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
listener.ExclusiveAddressUse = false;
listener.Client.Bind(new IPEndPoint(IPAddress.Any, multicastPort));
listener.JoinMulticastGroup(IPAddress.Parse(multicastAddress));
listener.Client.Bind(new IPEndPoint(IPAddress.Any, multicast_port));
listener.JoinMulticastGroup(IPAddress.Parse(multicast_address));
broadcaster = new UdpClient();
broadcaster.MulticastLoopback = false;
@@ -48,8 +48,8 @@ namespace osu.Game.Online.Discovery
{
try
{
var json = JsonConvert.SerializeObject(room);
var bytes = Encoding.UTF8.GetBytes(json);
string json = JsonConvert.SerializeObject(room);
byte[] bytes = Encoding.UTF8.GetBytes(json);
broadcaster.Send(bytes, bytes.Length, groupEndpoint);
}
catch (Exception e)
@@ -64,9 +64,9 @@ namespace osu.Game.Online.Discovery
{
try
{
var result = await listener.ReceiveAsync().ConfigureAwait(false);
var json = Encoding.UTF8.GetString(result.Buffer);
var room = JsonConvert.DeserializeObject<DiscoveredRoom>(json);
UdpReceiveResult result = await listener.ReceiveAsync(token).ConfigureAwait(false);
string json = Encoding.UTF8.GetString(result.Buffer);
DiscoveredRoom room = JsonConvert.DeserializeObject<DiscoveredRoom>(json);
if (room != null)
{
@@ -89,7 +89,7 @@ namespace osu.Game.Online.Discovery
try
{
cts.Cancel();
listener.DropMulticastGroup(IPAddress.Parse(multicastAddress));
listener.DropMulticastGroup(IPAddress.Parse(multicast_address));
}
catch { }
@@ -104,6 +104,7 @@ namespace osu.Game.Online.Discovery
public int RoomID { get; set; }
public string HostName { get; set; } = string.Empty;
public bool IsP2P { get; set; }
public int ControlPort { get; set; } = osu.Game.Online.LocalMultiplayer.LocalMultiplayerDirectServer.DEFAULT_PORT;
public IPEndPoint? AdvertiserEndpoint { get; set; }
public DateTimeOffset Timestamp { get; set; }
}

View File

@@ -0,0 +1,337 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using osu.Framework.Logging;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
namespace osu.Game.Online.LocalMultiplayer
{
/// <summary>
/// Lightweight direct bridge for local-only multiplayer clients.
/// Allows one client to query/join a room hosted by another client without remote server infrastructure.
/// </summary>
public class LocalMultiplayerDirectServer : IDisposable
{
public const int DEFAULT_PORT = 5326;
private const int protocol_version = 1;
private const int max_payload_length = 16 * 1024;
private readonly LocalMultiplayerServer localServer;
private readonly TcpListener listener;
private readonly CancellationTokenSource cancellation = new CancellationTokenSource();
public LocalMultiplayerDirectServer(LocalMultiplayerServer localServer, int port = DEFAULT_PORT)
{
this.localServer = localServer;
listener = new TcpListener(IPAddress.Any, port);
listener.Start();
Task.Run(() => acceptLoop(cancellation.Token));
}
private async Task acceptLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
var client = await listener.AcceptTcpClientAsync(token).ConfigureAwait(false);
_ = Task.Run(() => handleClient(client), token);
}
catch (ObjectDisposedException)
{
break;
}
catch (Exception ex)
{
Logger.Log($"[LocalDirect] accept failed: {ex}", LoggingTarget.Network, LogLevel.Debug);
await Task.Delay(200, token).ConfigureAwait(false);
}
}
}
private async Task handleClient(TcpClient client)
{
using (client)
using (var stream = client.GetStream())
using (var reader = new StreamReader(stream))
{
client.ReceiveTimeout = 3000;
client.SendTimeout = 3000;
stream.ReadTimeout = 3000;
stream.WriteTimeout = 3000;
using var writer = new StreamWriter(stream);
writer.AutoFlush = true;
try
{
string line = await reader.ReadLineAsync().ConfigureAwait(false);
if (string.IsNullOrEmpty(line))
return;
if (line.Length > max_payload_length)
{
await writer.WriteLineAsync(JsonConvert.SerializeObject(new Response
{
Success = false,
Error = "Payload too large."
})).ConfigureAwait(false);
return;
}
Request req = JsonConvert.DeserializeObject<Request>(line);
if (req == null)
return;
if (req.Version != protocol_version)
{
await writer.WriteLineAsync(JsonConvert.SerializeObject(new Response
{
Success = false,
Error = $"Unsupported protocol version '{req.Version}'."
})).ConfigureAwait(false);
return;
}
Response response = processRequest(req);
await writer.WriteLineAsync(JsonConvert.SerializeObject(response)).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log($"[LocalDirect] handle failed: {ex}", LoggingTarget.Network, LogLevel.Debug);
}
}
}
private Response processRequest(Request req)
{
try
{
switch (req.Op)
{
case "list_rooms":
return new Response
{
Success = true,
Rooms = localServer.GetRooms().ToArray()
};
case "get_room":
{
Room room = localServer.GetRoom(req.RoomId);
return room == null
? new Response { Success = false, Error = "Room not found." }
: new Response { Success = true, Room = room };
}
case "join_room":
{
var requested = new Room { RoomID = req.RoomId };
var user = new APIUser
{
Id = req.UserId,
Username = string.IsNullOrEmpty(req.Username) ? "Guest" : req.Username
};
var (success, room, error) = localServer.JoinRoom(requested, user, req.Password);
return success
? new Response { Success = true, Room = room }
: new Response { Success = false, Error = error ?? "Join failed." };
}
case "part_room":
{
var requested = new Room { RoomID = req.RoomId };
var user = new APIUser
{
Id = req.UserId,
Username = string.IsNullOrEmpty(req.Username) ? "Guest" : req.Username
};
localServer.PartRoom(requested, user);
return new Response { Success = true };
}
default:
return new Response { Success = false, Error = $"Unknown op '{req.Op}'" };
}
}
catch (Exception ex)
{
return new Response { Success = false, Error = ex.Message };
}
}
public void Dispose()
{
cancellation.Cancel();
try
{
listener.Stop();
}
catch
{
}
cancellation.Dispose();
}
private class Request
{
public int Version = protocol_version;
public string Op = string.Empty;
public long RoomId;
public string Password = string.Empty;
public int UserId;
public string Username = string.Empty;
}
private class Response
{
public bool Success;
public string Error = string.Empty;
public Room Room = new Room();
public Room[] Rooms = Array.Empty<Room>();
}
}
public static class LocalMultiplayerDirectClient
{
private const int protocol_version = 1;
public static bool TryJoinRoom(IPEndPoint endpoint, long roomId, string password, APIUser user, out Room room, out string error)
=> tryRequest(endpoint, new Request
{
Version = protocol_version,
Op = "join_room",
RoomId = roomId,
Password = password,
UserId = user?.Id ?? 0,
Username = user?.Username
}, out room, out _, out error);
public static bool TryGetRoom(IPEndPoint endpoint, long roomId, out Room room, out string error)
=> tryRequest(endpoint, new Request { Version = protocol_version, Op = "get_room", RoomId = roomId }, out room, out _, out error);
public static bool TryListRooms(IPEndPoint endpoint, out Room[] rooms, out string error)
{
bool success = tryRequest(endpoint, new Request { Version = protocol_version, Op = "list_rooms" }, out _, out rooms, out error);
rooms ??= Array.Empty<Room>();
return success;
}
public static bool TryPartRoom(IPEndPoint endpoint, long roomId, APIUser user, out string error)
=> tryRequest(endpoint, new Request
{
Version = protocol_version,
Op = "part_room",
RoomId = roomId,
UserId = user?.Id ?? 0,
Username = user?.Username
}, out _, out _, out error);
private static bool tryRequest(IPEndPoint endpoint, Request req, out Room room, out Room[] rooms, out string error)
{
room = null;
rooms = null;
error = null;
try
{
if (endpoint == null)
{
error = "Endpoint is null.";
return false;
}
using (var tcp = new TcpClient())
{
tcp.ReceiveTimeout = 3000;
tcp.SendTimeout = 3000;
var connectTask = tcp.ConnectAsync(endpoint.Address, endpoint.Port);
if (!connectTask.Wait(3000))
{
error = "Connect timeout";
return false;
}
using (var stream = tcp.GetStream())
using (var reader = new StreamReader(stream))
{
stream.ReadTimeout = 3000;
stream.WriteTimeout = 3000;
using var writer = new StreamWriter(stream);
writer.AutoFlush = true;
writer.WriteLine(JsonConvert.SerializeObject(req));
string line = reader.ReadLine();
if (string.IsNullOrEmpty(line))
{
error = "No response";
return false;
}
Response response = JsonConvert.DeserializeObject<Response>(line);
if (response == null)
{
error = "Invalid response";
return false;
}
if (!response.Success)
{
error = response.Error ?? "Remote request failed";
return false;
}
room = response.Room;
rooms = response.Rooms;
return true;
}
}
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
private class Request
{
public int Version;
public string Op = string.Empty;
public long RoomId;
public string Password = string.Empty;
public int UserId;
public string Username = string.Empty;
}
private class Response
{
public bool Success;
public string Error = string.Empty;
public Room Room = new Room();
public Room[] Rooms = Array.Empty<Room>();
}
}
}

View File

@@ -6,6 +6,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Online.Chat;
using osu.Game.Online.Rooms;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.API;
@@ -21,7 +22,9 @@ namespace osu.Game.Online.LocalMultiplayer
public class LocalMultiplayerServer
{
private readonly List<Room> rooms = new List<Room>();
private readonly Dictionary<long, List<Message>> channelMessages = new Dictionary<long, List<Message>>();
private int nextRoomId = 1;
private long nextMessageId = 1;
public IReadOnlyList<Room> GetRooms()
{
@@ -36,12 +39,15 @@ namespace osu.Game.Online.LocalMultiplayer
public APICreatedRoom CreateRoom(Room room, APIUser host)
{
room.RoomID = nextRoomId++;
room.ChannelId = (int)room.RoomID.Value;
room.StartDate = DateTimeOffset.Now;
room.EndDate = DateTimeOffset.Now.AddHours(2);
room.Host = host;
room.ParticipantCount = 1;
room.RecentParticipants = new[] { host };
ensureChannel(room.ChannelId);
var stored = new Room();
stored.CopyFrom(room);
rooms.Add(stored);
@@ -57,7 +63,14 @@ namespace osu.Game.Online.LocalMultiplayer
/// </summary>
public void UpsertRoom(Room room)
{
if (room.RoomID != null && room.ChannelId == 0)
room.ChannelId = (int)room.RoomID.Value;
if (room.ChannelId != 0)
ensureChannel(room.ChannelId);
var existing = rooms.SingleOrDefault(r => r.RoomID == room.RoomID);
if (existing != null)
existing.CopyFrom(room);
else
@@ -72,11 +85,66 @@ namespace osu.Game.Online.LocalMultiplayer
{
var found = rooms.SingleOrDefault(r => r.RoomID == id);
if (found == null) return null;
if (found.ChannelId == 0 && found.RoomID != null)
{
found.ChannelId = (int)found.RoomID.Value;
ensureChannel(found.ChannelId);
}
var copy = new Room();
copy.CopyFrom(found);
return copy;
}
public IReadOnlyList<Channel> GetChannels()
{
return rooms.Where(r => r.RoomID != null)
.Select(r =>
{
long channelId = r.ChannelId != 0 ? r.ChannelId : r.RoomID!.Value;
ensureChannel(channelId);
return new Channel
{
Id = channelId,
Name = $"#lazermp_{r.RoomID.Value}",
Topic = r.Name,
Type = ChannelType.Multiplayer,
LastMessageId = channelMessages.TryGetValue(channelId, out List<Message>? msgs) && msgs.Count > 0
? msgs[^1].Id
: 0,
};
})
.ToArray();
}
public IReadOnlyList<Message> GetChannelMessages(long channelId)
{
if (!channelMessages.TryGetValue(channelId, out List<Message>? messages))
return Array.Empty<Message>();
return messages.Select(cloneMessage).ToArray();
}
public Message PostChannelMessage(Message incoming, APIUser sender)
{
ensureChannel(incoming.ChannelId);
var posted = new Message(nextMessageId++)
{
ChannelId = incoming.ChannelId,
Content = incoming.Content,
IsAction = incoming.IsAction,
Timestamp = DateTimeOffset.Now,
Sender = sender,
Uuid = incoming.Uuid,
};
channelMessages[incoming.ChannelId].Add(posted);
return cloneMessage(posted);
}
public (bool success, Room? room, string? error) JoinRoom(Room requested, APIUser user, string? password)
{
var room = rooms.SingleOrDefault(r => r.RoomID == requested.RoomID);
@@ -203,5 +271,24 @@ namespace osu.Game.Online.LocalMultiplayer
var now = DateTimeOffset.Now;
rooms.RemoveAll(r => r.EndDate != null && r.EndDate <= now);
}
private void ensureChannel(long channelId)
{
if (!channelMessages.ContainsKey(channelId))
channelMessages[channelId] = new List<Message>();
}
private static Message cloneMessage(Message source)
{
return new Message(source.Id)
{
ChannelId = source.ChannelId,
IsAction = source.IsAction,
Timestamp = source.Timestamp,
Content = source.Content,
Sender = source.Sender,
Uuid = source.Uuid,
};
}
}
}

View File

@@ -44,6 +44,8 @@ namespace osu.Game.Online.Multiplayer
private long lastPlaylistItemId;
private int lastCountdownId;
private string? hostSignalling;
private readonly Dictionary<int, string> peerSignalling = new Dictionary<int, string>();
private readonly Dictionary<int, long> matchmakingUserPicks = new Dictionary<int, long>();
@@ -61,6 +63,9 @@ namespace osu.Game.Online.Multiplayer
if (localServer == null)
throw new InvalidOperationException("Local server not available");
if (api.LocalUser.Value == null)
throw new InvalidOperationException("Local user not available");
// Mirror TestMultiplayerClient behaviour: create an API Room and add server-side room.
var apiRoom = new Room(room)
{
@@ -79,6 +84,9 @@ namespace osu.Game.Online.Multiplayer
if (localServer == null)
throw new InvalidOperationException("Local server not available");
if (api.LocalUser.Value == null)
throw new InvalidOperationException("Local user not available");
var apiRoom = localServer.GetRoom(roomId);
if (apiRoom == null)
throw new InvalidOperationException("Room not found.");
@@ -117,6 +125,8 @@ namespace osu.Game.Online.Multiplayer
serverApiRoom = null;
serverRoom = null;
hostSignalling = null;
peerSignalling.Clear();
return Task.CompletedTask;
}
@@ -126,11 +136,12 @@ namespace osu.Game.Online.Multiplayer
return Task.CompletedTask;
}
public override Task InvitePlayer(int userId) => Task.CompletedTask;
public override Task InvitePlayer(int userId)
=> Task.CompletedTask;
public override Task TransferHost(int userId)
{
MultiplayerRoom room = serverRoom;
MultiplayerRoom? room = serverRoom;
if (room == null)
return Task.CompletedTask;
@@ -163,27 +174,44 @@ namespace osu.Game.Online.Multiplayer
return Task.CompletedTask;
}
public override Task ChangeState(MultiplayerUserState newState)
public override async Task ChangeState(MultiplayerUserState newState)
{
if (serverRoom == null)
return Task.CompletedTask;
return;
var local = serverRoom.Users.SingleOrDefault(u => u.User?.Id == api.LocalUser.Value.Id);
if (local == null) return Task.CompletedTask;
var localApiUser = api.LocalUser.Value;
if (localApiUser == null)
return;
MultiplayerRoomUser? local = serverRoom.Users.SingleOrDefault(u => u.User?.Id == localApiUser.Id);
if (local == null)
return;
local.State = newState;
((IMultiplayerClient)this).UserStateChanged(local.UserID, local.State);
return Task.CompletedTask;
await ((IMultiplayerClient)this).UserStateChanged(local.UserID, local.State).ConfigureAwait(false);
if (newState == MultiplayerUserState.ReadyForGameplay)
await tryStartGameplay().ConfigureAwait(false);
if (newState == MultiplayerUserState.FinishedPlay)
await tryPublishResults().ConfigureAwait(false);
if (newState == MultiplayerUserState.Idle)
await ensureRoomOpenState().ConfigureAwait(false);
}
public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability)
{
MultiplayerRoom room = serverRoom;
MultiplayerRoom? room = serverRoom;
if (room == null)
return Task.CompletedTask;
var local = room.Users.SingleOrDefault(u => u.User?.Id == api.LocalUser.Value.Id);
var localApiUser = api.LocalUser.Value;
if (localApiUser == null)
return Task.CompletedTask;
MultiplayerRoomUser? local = room.Users.SingleOrDefault(u => u.User?.Id == localApiUser.Id);
if (local == null)
return Task.CompletedTask;
@@ -195,12 +223,16 @@ namespace osu.Game.Online.Multiplayer
public override Task ChangeUserStyle(int? beatmapId, int? rulesetId)
{
MultiplayerRoom room = serverRoom;
MultiplayerRoom? room = serverRoom;
if (room == null)
return Task.CompletedTask;
var local = room.Users.SingleOrDefault(u => u.User?.Id == api.LocalUser.Value.Id);
var localApiUser = api.LocalUser.Value;
if (localApiUser == null)
return Task.CompletedTask;
MultiplayerRoomUser? local = room.Users.SingleOrDefault(u => u.User?.Id == localApiUser.Id);
if (local == null)
return Task.CompletedTask;
@@ -213,12 +245,16 @@ namespace osu.Game.Online.Multiplayer
public override Task ChangeUserMods(IEnumerable<APIMod> newMods)
{
MultiplayerRoom room = serverRoom;
MultiplayerRoom? room = serverRoom;
if (room == null)
return Task.CompletedTask;
var local = room.Users.SingleOrDefault(u => u.User?.Id == api.LocalUser.Value.Id);
var localApiUser = api.LocalUser.Value;
if (localApiUser == null)
return Task.CompletedTask;
MultiplayerRoomUser? local = room.Users.SingleOrDefault(u => u.User?.Id == localApiUser.Id);
if (local == null)
return Task.CompletedTask;
@@ -247,35 +283,60 @@ namespace osu.Game.Online.Multiplayer
return Task.CompletedTask;
}
public override Task StartMatch()
public override async Task StartMatch()
{
if (serverRoom == null) return Task.CompletedTask;
if (serverRoom == null)
return;
serverRoom.State = MultiplayerRoomState.WaitingForLoad;
foreach (var user in serverRoom.Users.Where(u => u.State == MultiplayerUserState.Ready))
user.State = MultiplayerUserState.WaitingForLoad;
await ((IMultiplayerClient)this).RoomStateChanged(serverRoom.State).ConfigureAwait(false);
return ((IMultiplayerClient)this).LoadRequested();
foreach (var user in serverRoom.Users.Where(u => u.State == MultiplayerUserState.Ready).ToList())
{
user.State = MultiplayerUserState.WaitingForLoad;
await ((IMultiplayerClient)this).UserStateChanged(user.UserID, user.State).ConfigureAwait(false);
}
await ((IMultiplayerClient)this).LoadRequested().ConfigureAwait(false);
}
public override Task AbortGameplay()
{
var local = serverRoom?.Users.SingleOrDefault(u => u.User?.Id == api.LocalUser.Value.Id);
if (local != null) local.State = MultiplayerUserState.Idle;
return Task.CompletedTask;
var localApiUser = api.LocalUser.Value;
if (localApiUser == null)
return Task.CompletedTask;
MultiplayerRoomUser? local = serverRoom?.Users.SingleOrDefault(u => u.User?.Id == localApiUser.Id);
if (local != null)
local.State = MultiplayerUserState.Idle;
return ensureRoomOpenState();
}
public override Task AbortMatch()
{
var local = serverRoom?.Users.SingleOrDefault(u => u.User?.Id == api.LocalUser.Value.Id);
if (local != null) local.State = MultiplayerUserState.Idle;
return ((IMultiplayerClient)this).GameplayAborted(GameplayAbortReason.HostAbortedTheMatch);
var localApiUser = api.LocalUser.Value;
if (localApiUser == null)
return Task.CompletedTask;
MultiplayerRoomUser? local = serverRoom?.Users.SingleOrDefault(u => u.User?.Id == localApiUser.Id);
if (local != null)
local.State = MultiplayerUserState.Idle;
return Task.Run(async () =>
{
await ensureRoomOpenState().ConfigureAwait(false);
await ((IMultiplayerClient)this).GameplayAborted(GameplayAbortReason.HostAbortedTheMatch).ConfigureAwait(false);
});
}
public override async Task AddPlaylistItem(MultiplayerPlaylistItem item)
{
if (serverRoom == null) throw new InvalidOperationException("Not in a room");
if (api.LocalUser.Value == null)
throw new InvalidOperationException("Local user not available");
item.ID = ++lastPlaylistItemId;
item.OwnerID = api.LocalUser.Value.OnlineID;
serverRoom.Playlist.Add(item);
@@ -304,10 +365,30 @@ namespace osu.Game.Online.Multiplayer
public override Task VoteToSkipIntro()
{
if (api.LocalUser.Value == null)
return Task.CompletedTask;
// simple immediate vote
return ((IMultiplayerClient)this).UserVotedToSkipIntro(api.LocalUser.Value.OnlineID, true);
}
public override Task UploadHostSignalling(string signalling)
{
hostSignalling = signalling;
return Task.CompletedTask;
}
public override Task UploadPeerSignalling(int userId, string signalling)
{
peerSignalling[userId] = signalling;
return Task.CompletedTask;
}
public override Task<string?> GetHostSignalling() => Task.FromResult(hostSignalling);
public override Task<IDictionary<int, string>?> GetPeerSignalling()
=> Task.FromResult<IDictionary<int, string>?>(new Dictionary<int, string>(peerSignalling));
public async Task StartCountdown(MultiplayerCountdown countdown)
{
if (serverRoom == null) return;
@@ -354,7 +435,12 @@ namespace osu.Game.Online.Multiplayer
public override Task MatchmakingDeclineInvitation() => Task.CompletedTask;
public override Task MatchmakingToggleSelection(long playlistItemId)
=> MatchmakingToggleUserSelection(api.LocalUser.Value.OnlineID, playlistItemId);
{
if (api.LocalUser.Value == null)
return Task.CompletedTask;
return MatchmakingToggleUserSelection(api.LocalUser.Value.OnlineID, playlistItemId);
}
public override Task MatchmakingSkipToNextStage() => Task.CompletedTask;
@@ -373,6 +459,82 @@ namespace osu.Game.Online.Multiplayer
await ((IMatchmakingClient)this).MatchmakingItemSelected(clone(userId), clone(playlistItemId)).ConfigureAwait(false);
}
private async Task tryStartGameplay()
{
if (serverRoom == null)
return;
if (serverRoom.State != MultiplayerRoomState.WaitingForLoad)
return;
var participants = serverRoom.Users
.Where(u => isActiveMatchParticipantState(u.State))
.ToList();
if (participants.Count == 0)
return;
bool allReadyToPlay = participants.All(u => u.State == MultiplayerUserState.ReadyForGameplay || u.State == MultiplayerUserState.Playing);
if (!allReadyToPlay)
return;
foreach (var user in participants.Where(u => u.State == MultiplayerUserState.ReadyForGameplay).ToList())
{
user.State = MultiplayerUserState.Playing;
await ((IMultiplayerClient)this).UserStateChanged(user.UserID, user.State).ConfigureAwait(false);
}
serverRoom.State = MultiplayerRoomState.Playing;
await ((IMultiplayerClient)this).RoomStateChanged(serverRoom.State).ConfigureAwait(false);
await ((IMultiplayerClient)this).GameplayStarted().ConfigureAwait(false);
}
private async Task tryPublishResults()
{
if (serverRoom == null)
return;
var finishedUsers = serverRoom.Users
.Where(u => u.State == MultiplayerUserState.FinishedPlay)
.ToList();
if (finishedUsers.Count == 0)
return;
bool hasAnyPlaying = serverRoom.Users.Any(u => u.State == MultiplayerUserState.Playing);
if (hasAnyPlaying)
return;
foreach (var user in finishedUsers)
{
user.State = MultiplayerUserState.Results;
await ((IMultiplayerClient)this).UserStateChanged(user.UserID, user.State).ConfigureAwait(false);
}
serverRoom.State = MultiplayerRoomState.Open;
await ((IMultiplayerClient)this).RoomStateChanged(serverRoom.State).ConfigureAwait(false);
await ((IMultiplayerClient)this).ResultsReady().ConfigureAwait(false);
}
private static bool isActiveMatchParticipantState(MultiplayerUserState state)
=> state == MultiplayerUserState.WaitingForLoad
|| state == MultiplayerUserState.Loaded
|| state == MultiplayerUserState.ReadyForGameplay
|| state == MultiplayerUserState.Playing;
private async Task ensureRoomOpenState()
{
if (serverRoom == null)
return;
if (serverRoom.State == MultiplayerRoomState.Open)
return;
serverRoom.State = MultiplayerRoomState.Open;
await ((IMultiplayerClient)this).RoomStateChanged(serverRoom.State).ConfigureAwait(false);
}
private T clone<T>(T incoming)
{
byte[] serialized = MessagePackSerializer.Serialize(typeof(T), incoming, SignalRUnionWorkaroundResolver.OPTIONS);

View File

@@ -13,6 +13,7 @@ using osu.Framework.Development;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Game.Database;
using osu.Game.LAsEzExtensions.Configuration;
using osu.Game.Localisation;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
@@ -124,10 +125,12 @@ namespace osu.Game.Online.Multiplayer
public event Action? MatchmakingQueueJoined;
public event Action? MatchmakingQueueLeft;
/// <summary>
/// Invoked to report progress/status of the experimental P2P handshake flow.
/// </summary>
public event Action<string>? P2PHandshakeStatusChanged;
public event Action? MatchmakingRoomInvited;
public event Action<long, string>? MatchmakingRoomReady;
public event Action<MatchmakingLobbyStatus>? MatchmakingLobbyStatusChanged;
@@ -147,6 +150,15 @@ namespace osu.Game.Online.Multiplayer
/// </summary>
public abstract IBindable<bool> IsConnected { get; }
/// <summary>
/// Whether multiplayer is usable from UI perspective.
/// True when connected to multiplayer backend, running in local-only mode, or experimental P2P is enabled.
/// This is NOT thread safe and usage should be scheduled.
/// </summary>
public IBindable<bool> IsMultiplayerUsable => isMultiplayerUsable;
private readonly BindableBool isMultiplayerUsable = new BindableBool();
/// <summary>
/// The joined <see cref="MultiplayerRoom"/>.
/// </summary>
@@ -186,6 +198,7 @@ namespace osu.Game.Online.Multiplayer
/// Retrieves the peer signalling payloads stored for the current room.
/// </summary>
public virtual Task<IDictionary<int, string>?> GetPeerSignalling() => Task.FromResult<IDictionary<int, string>?>(null);
/// <summary>
/// The users in the joined <see cref="Room"/> which are participating in the current gameplay loop.
/// </summary>
@@ -224,6 +237,11 @@ namespace osu.Game.Online.Multiplayer
[Resolved]
private UserLookupCache userLookupCache { get; set; } = null!;
[Resolved]
private Ez2ConfigManager ezConfig { get; set; } = null!;
private IBindable<bool> p2PEnabled = null!;
protected Room? APIRoom { get; private set; }
private readonly Queue<Action> pendingRequests = new Queue<Action>();
@@ -231,6 +249,12 @@ namespace osu.Game.Online.Multiplayer
[BackgroundDependencyLoader]
private void load()
{
p2PEnabled = ezConfig.GetBindable<bool>(Ez2Setting.ExperimentalP2P).GetBoundCopy();
IsConnected.BindValueChanged(_ => Scheduler.Add(updateMultiplayerUsability));
API.State.BindValueChanged(_ => Scheduler.Add(updateMultiplayerUsability));
p2PEnabled.BindValueChanged(_ => Scheduler.Add(updateMultiplayerUsability), true);
IsConnected.BindValueChanged(connected => Scheduler.Add(() =>
{
if (!connected.NewValue)
@@ -243,6 +267,9 @@ namespace osu.Game.Online.Multiplayer
}));
}
private void updateMultiplayerUsability()
=> isMultiplayerUsable.Value = IsConnected.Value || API.IsLocalOnly || p2PEnabled.Value;
private readonly TaskChain joinOrLeaveTaskChain = new TaskChain();
private CancellationTokenSource? joinCancellationSource;
@@ -263,6 +290,7 @@ namespace osu.Game.Online.Multiplayer
await runOnUpdateThreadAsync(() => pendingRequests.Clear(), cancellationSource.Token).ConfigureAwait(false);
var multiplayerRoom = await CreateRoomInternal(new MultiplayerRoom(room)).ConfigureAwait(false);
await setupJoinedRoom(room, multiplayerRoom, cancellationSource.Token).ConfigureAwait(false);
// If created as experimental P2P and we're the host, start offer generation and upload + poll for peer answers.
if (Room != null && Room.IsP2P && IsHost)
{
@@ -288,6 +316,7 @@ namespace osu.Game.Online.Multiplayer
for (int i = 0; i < 10; i++)
{
var peerSigs = await GetPeerSignalling().ConfigureAwait(false);
if (peerSigs != null && peerSigs.Count > 0)
{
foreach (var kv in peerSigs)
@@ -397,6 +426,7 @@ namespace osu.Game.Online.Multiplayer
try
{
var hostSig = await GetHostSignalling().ConfigureAwait(false);
if (!string.IsNullOrEmpty(hostSig))
{
await runOnUpdateThreadAsync(() => Room!.HostSignalling = hostSig, cancellationToken).ConfigureAwait(false);
@@ -404,6 +434,7 @@ namespace osu.Game.Online.Multiplayer
}
var peerSigs = await GetPeerSignalling().ConfigureAwait(false);
if (peerSigs != null)
{
await runOnUpdateThreadAsync(() =>
@@ -411,6 +442,7 @@ namespace osu.Game.Online.Multiplayer
Room!.PeerSignalling = peerSigs.ToDictionary(k => k.Key, k => k.Value);
}, cancellationToken).ConfigureAwait(false);
}
// If host signalling is available, automatically generate an answer and upload it.
if (!string.IsNullOrEmpty(hostSig))
{
@@ -425,6 +457,7 @@ namespace osu.Game.Online.Multiplayer
await webRtc.InitializeAsync().ConfigureAwait(false);
var answer = await webRtc.CreateAnswerAsync(hostSig).ConfigureAwait(false);
var local = API.LocalUser.Value;
if (local != null)
{
await UploadPeerSignalling(local.Id, answer).ConfigureAwait(false);
@@ -467,6 +500,7 @@ namespace osu.Game.Online.Multiplayer
PlayingUserIds.Clear();
RoomUpdated?.Invoke();
if (webRtcOwned && webRtc != null)
{
try
@@ -474,6 +508,7 @@ namespace osu.Game.Online.Multiplayer
webRtc.Dispose();
}
catch { }
webRtc = null;
webRtcOwned = false;
}

View File

@@ -156,7 +156,6 @@ namespace osu.Game.Online.Multiplayer
Debug.Assert(connection != null);
try
{
await connection.InvokeAsync("InvitePlayer", userId).ConfigureAwait(false);
@@ -183,7 +182,7 @@ namespace osu.Game.Online.Multiplayer
Debug.Assert(connection != null);
return connection.InvokeAsync("TransferHost", userId);
return connection.InvokeAsync("TransferHost", userId);
}
public override Task KickUser(int userId)
@@ -193,7 +192,7 @@ namespace osu.Game.Online.Multiplayer
Debug.Assert(connection != null);
return connection.InvokeAsync("KickUser", userId);
return connection.InvokeAsync("KickUser", userId);
}
public override Task ChangeSettings(MultiplayerRoomSettings settings)
@@ -203,7 +202,7 @@ namespace osu.Game.Online.Multiplayer
Debug.Assert(connection != null);
return connection.InvokeAsync("ChangeSettings", settings);
return connection.InvokeAsync("ChangeSettings", settings);
}
public override Task ChangeState(MultiplayerUserState newState)
@@ -213,7 +212,7 @@ namespace osu.Game.Online.Multiplayer
Debug.Assert(connection != null);
return connection.InvokeAsync("ChangeState", newState);
return connection.InvokeAsync("ChangeState", newState);
}
public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability)
@@ -223,7 +222,7 @@ namespace osu.Game.Online.Multiplayer
Debug.Assert(connection != null);
return connection.InvokeAsync("ChangeBeatmapAvailability", newBeatmapAvailability);
return connection.InvokeAsync("ChangeBeatmapAvailability", newBeatmapAvailability);
}
public override Task ChangeUserStyle(int? beatmapId, int? rulesetId)
@@ -233,7 +232,7 @@ namespace osu.Game.Online.Multiplayer
Debug.Assert(connection != null);
return connection.InvokeAsync("ChangeUserStyle", beatmapId, rulesetId);
return connection.InvokeAsync("ChangeUserStyle", beatmapId, rulesetId);
}
public override Task ChangeUserMods(IEnumerable<APIMod> newMods)
@@ -243,7 +242,7 @@ namespace osu.Game.Online.Multiplayer
Debug.Assert(connection != null);
return connection.InvokeAsync("ChangeUserMods", newMods);
return connection.InvokeAsync("ChangeUserMods", newMods);
}
public override Task UploadHostSignalling(string signalling)

View File

@@ -61,7 +61,7 @@ namespace osu.Game.Online
{
apiState = api.State.GetBoundCopy();
notificationsClient = api.NotificationsClient;
multiplayerState = multiplayerClient.IsConnected.GetBoundCopy();
multiplayerState = multiplayerClient.IsMultiplayerUsable.GetBoundCopy();
spectatorState = spectatorClient.IsConnected.GetBoundCopy();
notificationsClient.MessageReceived += notifyAboutForcedDisconnection;

View File

@@ -7,6 +7,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Linq;
using MessagePack;
using osu.Game.Online.API;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mods;
using osu.Game.Utils;
@@ -95,7 +96,7 @@ namespace osu.Game.Online.Rooms
ID = item.ID;
OwnerID = item.OwnerID;
BeatmapID = item.Beatmap.OnlineID;
BeatmapChecksum = item.Beatmap.MD5Hash;
BeatmapChecksum = getBestChecksum(item.Beatmap);
RulesetID = item.RulesetID;
RequiredMods = item.RequiredMods.ToArray();
AllowedMods = item.AllowedMods.ToArray();
@@ -156,5 +157,22 @@ namespace osu.Game.Online.Rooms
hashCode.Add(Freestyle);
return hashCode.ToHashCode();
}
private static string getBestChecksum(IBeatmapInfo beatmap)
{
if (beatmap is BeatmapInfo localBeatmap)
{
if (!string.IsNullOrEmpty(localBeatmap.MD5Hash))
return localBeatmap.MD5Hash;
if (!string.IsNullOrEmpty(localBeatmap.OnlineMD5Hash))
return localBeatmap.OnlineMD5Hash;
if (!string.IsNullOrEmpty(localBeatmap.Hash))
return localBeatmap.Hash;
}
return beatmap.MD5Hash ?? string.Empty;
}
}
}

View File

@@ -361,9 +361,11 @@ namespace osu.Game
// TODO: OsuGame or OsuGameBase?
dependencies.CacheAs(beatmapUpdater = CreateBeatmapUpdater());
dependencies.CacheAs(SpectatorClient = new OnlineSpectatorClient(endpoints));
// If API is local-only (experimental P2P / local account), use a local multiplayer client
// If API is local-only or experimental P2P is enabled, use a local multiplayer client
// which provides `IsConnected` and multiplayer event semantics backed by an in-memory server.
if (API is APIAccess access && access.IsLocalOnly)
bool p2pEnabled = Ez2ConfigManager.GetBindable<bool>(Ez2Setting.ExperimentalP2P).Value;
if ((API is APIAccess access && access.IsLocalOnly) || p2pEnabled)
dependencies.CacheAs(MultiplayerClient = new LocalMultiplayerClient());
else
dependencies.CacheAs(MultiplayerClient = new OnlineMultiplayerClient(endpoints));

View File

@@ -128,8 +128,16 @@ namespace osu.Game.Overlays
req.Success += res =>
{
beatmapSet.Value = res;
if (lastLookup.Value.type == BeatmapSetLookupType.BeatmapId)
Header.HeaderContent.Picker.Beatmap.Value = Header.BeatmapSet.Value.Beatmaps.First(b => b.OnlineID == lastLookup.Value.id);
{
var beatmaps = Header.BeatmapSet.Value?.Beatmaps;
var selected = beatmaps?.FirstOrDefault(b => b.OnlineID == lastLookup.Value.id)
?? beatmaps?.FirstOrDefault();
if (selected != null)
Header.HeaderContent.Picker.Beatmap.Value = selected;
}
};
API.Queue(req);
}

View File

@@ -38,6 +38,7 @@ using osu.Game.Users.Drawables;
using osuTK;
using osuTK.Graphics;
using osu.Game.Localisation;
using osu.Game.Online.API;
namespace osu.Game.Screens.OnlinePlay
{
@@ -121,6 +122,9 @@ namespace osu.Game.Screens.OnlinePlay
[Resolved(CanBeNull = true)]
private ManageCollectionsDialog? manageCollectionsDialog { get; set; }
[Resolved]
private IAPIProvider api { get; set; } = null!;
public DrawableRoomPlaylistItem(PlaylistItem item, bool loadImmediately = false)
: base(item)
{
@@ -187,6 +191,15 @@ namespace osu.Game.Screens.OnlinePlay
beatmap = await beatmapLookupCache.GetBeatmapAsync(Item.Beatmap.OnlineID).ConfigureAwait(false);
if (api.IsLocalOnly)
{
if (!string.IsNullOrEmpty(Item.Beatmap.MD5Hash))
beatmap = beatmaps.QueryBeatmap(b => b.MD5Hash == Item.Beatmap.MD5Hash) ?? beatmap;
if (beatmap == null && Item.Beatmap.OnlineID > 0)
beatmap = beatmaps.QueryOnlineBeatmapId(Item.Beatmap.OnlineID) ?? beatmap;
}
Scheduler.AddOnce(refresh);
}
catch (Exception e)
@@ -296,9 +309,11 @@ namespace osu.Game.Screens.OnlinePlay
{
if (beatmap != null)
{
difficultyIconContainer.Children = new Drawable[]
Drawable thumbnailDrawable;
if (beatmap.BeatmapSet is IBeatmapSetOnlineInfo onlineInfo)
{
thumbnail = new BeatmapCardThumbnail(beatmap.BeatmapSet!, (IBeatmapSetOnlineInfo)beatmap.BeatmapSet!)
thumbnailDrawable = thumbnail = new BeatmapCardThumbnail(beatmap.BeatmapSet, onlineInfo)
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
@@ -307,7 +322,24 @@ namespace osu.Game.Screens.OnlinePlay
CornerRadius = 10,
RelativeSizeAxes = Axes.Y,
Dimmed = { Value = IsHovered }
},
};
}
else
{
thumbnail = null;
thumbnailDrawable = new Box
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Width = 60,
RelativeSizeAxes = Axes.Y,
Colour = OsuColour.Gray(0.15f),
};
}
difficultyIconContainer.Children = new[]
{
thumbnailDrawable,
new DifficultyIcon(beatmap, ruleset, requiredMods)
{
Size = new Vector2(24),
@@ -539,11 +571,17 @@ namespace osu.Game.Screens.OnlinePlay
d.Anchor = Anchor.Centre;
d.Origin = Anchor.Centre;
})
: new PlaylistDownloadButton(beatmap)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
: api.IsLocalOnly
? Empty().With(d =>
{
d.Anchor = Anchor.Centre;
d.Origin = Anchor.Centre;
})
: new PlaylistDownloadButton(beatmap)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
showResultsButton = new GrayButton(FontAwesome.Solid.ChartPie)
{
Anchor = Anchor.Centre,

View File

@@ -203,6 +203,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge
base.LoadComplete();
searchTextBox.Current.BindValueChanged(_ => updateFilterDebounced());
searchTextBox.OnCommit += (_, _) => tryHandleDirectConnectCommand();
ruleset.BindValueChanged(_ => UpdateFilter());
isIdle.BindValueChanged(_ => updatePollingRate(this.IsCurrentScreen()), true);
@@ -224,6 +225,39 @@ namespace osu.Game.Screens.OnlinePlay.Lounge
updateFilter();
}
private void tryHandleDirectConnectCommand()
{
string text = searchTextBox.Current.Value?.Trim() ?? string.Empty;
const string command_with_slash = "/connect ";
const string command_plain = "connect ";
string? endpoint = null;
if (text.StartsWith(command_with_slash, StringComparison.OrdinalIgnoreCase))
endpoint = text[command_with_slash.Length..].Trim();
else if (text.StartsWith(command_plain, StringComparison.OrdinalIgnoreCase))
endpoint = text[command_plain.Length..].Trim();
if (string.IsNullOrEmpty(endpoint))
return;
if (api is not APIAccess apiAccess)
{
Logger.Log("Direct connect is unavailable in this API provider.", LoggingTarget.Runtime, LogLevel.Important);
return;
}
if (!apiAccess.TryDiscoverRemoteHost(endpoint, out string error, out int discoveredCount))
{
Logger.Log($"Direct connect failed: {error}", LoggingTarget.Runtime, LogLevel.Important);
return;
}
Logger.Log($"Direct connect discovered {discoveredCount} room(s) from {endpoint}.", LoggingTarget.Runtime, LogLevel.Important);
RefreshRooms();
}
private void onListingReceived(Room[] result)
{
Dictionary<long, Room> localRoomsById = roomListing.Rooms.ToDictionary(r => r.RoomID!.Value);

View File

@@ -24,7 +24,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
Text = "Create room";
isConnected = multiplayerClient.IsConnected.GetBoundCopy();
isConnected = multiplayerClient.IsMultiplayerUsable.GetBoundCopy();
operationInProgress = ongoingOperationTracker.InProgress.GetBoundCopy();
}

View File

@@ -11,15 +11,17 @@ using osu.Framework.Extensions;
using osu.Framework.Extensions.ExceptionExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Online.Multiplayer.WebRtc;
using osu.Game.Online.API;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Platform;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.LAsEzExtensions.Configuration;
using osu.Game.Online.API;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.WebRtc;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Screens.OnlinePlay.Match.Components;
@@ -66,10 +68,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
public OsuEnumDropdown<QueueMode> QueueModeDropdown = null!;
public OsuTextBox PasswordTextBox = null!;
public OsuCheckbox AutoSkipCheckbox = null!;
public OsuCheckbox P2PCheckbox = null!;
public RoundedButton ApplyButton = null!;
public OsuSpriteText ErrorText = null!;
public OsuSpriteText P2PStatusText = null!;
#if DEBUG
private OsuTextBox signallingBox = null!;
private OsuButton generateOfferButton = null!;
private OsuButton uploadHostButton = null!;
@@ -77,6 +80,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private OsuButton generateAnswerButton = null!;
private OsuButton uploadAnswerButton = null!;
private OsuButton fetchPeerAnswersButton = null!;
private OsuButton copySignallingLinkButton = null!;
private OsuButton importSignallingLinkButton = null!;
#endif
private OsuEnumDropdown<StartMode> startModeDropdown = null!;
private OsuSpriteText typeLabel = null!;
@@ -96,6 +102,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
[Resolved]
private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!;
[Resolved]
private Ez2ConfigManager ezConfig { get; set; } = null!;
#if DEBUG
[Resolved]
private Clipboard clipboard { get; set; } = null!;
#endif
[Resolved(CanBeNull = true)]
private WebRtcManager? webRtc { get; set; }
@@ -104,7 +118,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private readonly IBindable<bool> operationInProgress = new BindableBool();
private readonly Room room;
private Action<string>? p2pStatusHandler;
private Action<string>? p2PStatusHandler;
private IDisposable? applyingSettingsOperation;
private Drawable playlistContainer = null!;
@@ -267,13 +281,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{
LabelText = "Automatically skip the beatmap intro"
}
},
new Section("Experimental")
{
Child = P2PCheckbox = new OsuCheckbox
{
LabelText = "Experimental P2P (host)"
}
}
}
}
@@ -335,33 +342,47 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
Padding = new MarginPadding { Horizontal = OsuScreen.HORIZONTAL_OVERFLOW_PADDING },
Children = new Drawable[]
{
// signalling tools for experimental P2P (manual exchange)
signallingBox = new OsuTextBox
{
RelativeSizeAxes = Axes.X,
Height = 80,
PlaceholderText = "Signalling (offer/answer) payload",
},
new FillFlowContainer
{
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(5,5),
Children = new Drawable[]
{
generateOfferButton = new PurpleRoundedButton { Text = "Generate Offer" , Action = () => generateOffer()},
uploadHostButton = new PurpleRoundedButton { Text = "Upload Host Offer" , Action = () => uploadHostSignalling()},
fetchHostButton = new PurpleRoundedButton { Text = "Fetch Host Offer" , Action = () => fetchHostSignalling()},
generateAnswerButton = new PurpleRoundedButton { Text = "Generate Answer" , Action = () => generateAnswer()},
uploadAnswerButton = new PurpleRoundedButton { Text = "Upload Answer" , Action = () => uploadPeerSignalling()},
fetchPeerAnswersButton = new PurpleRoundedButton { Text = "Fetch Peer Answers" , Action = () => fetchPeerAnswers()},
#if DEBUG
new PurpleRoundedButton { Text = "Debug: P2P Test", Action = () => runDebugP2PTest() },
// signalling tools for experimental P2P (manual exchange)
signallingBox = new OsuTextBox
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
RelativeSizeAxes = Axes.X,
Height = 80,
PlaceholderText = "Signalling payload or ez2p2p://signal?... link",
},
new OsuSpriteText
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Text = "Host: Generate+Upload offer. Peer: Fetch+Generate answer+Upload. Share via Copy Link / Import Link.",
},
new FillFlowContainer
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(5, 5),
Children = new Drawable[]
{
generateOfferButton = new PurpleRoundedButton { Text = "Generate Offer", Action = () => generateOffer() },
uploadHostButton = new PurpleRoundedButton { Text = "Upload Host Offer", Action = () => uploadHostSignalling() },
fetchHostButton = new PurpleRoundedButton { Text = "Fetch Host Offer", Action = () => fetchHostSignalling() },
generateAnswerButton = new PurpleRoundedButton { Text = "Generate Answer", Action = () => generateAnswer() },
uploadAnswerButton = new PurpleRoundedButton { Text = "Upload Answer", Action = () => uploadPeerSignalling() },
fetchPeerAnswersButton = new PurpleRoundedButton { Text = "Fetch Peer Answers", Action = () => fetchPeerAnswers() },
copySignallingLinkButton = new PurpleRoundedButton { Text = "Copy Link", Action = () => copySignallingLink() },
importSignallingLinkButton = new PurpleRoundedButton { Text = "Import Link", Action = () => importSignallingFromClipboard() },
#if DEBUG
new PurpleRoundedButton { Text = "Debug: P2P Test", Action = () => runDebugP2PTest() },
#endif
}
},
ApplyButton = new CreateOrUpdateButton(room)
}
},
#endif
ApplyButton = new CreateOrUpdateButton(room)
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
@@ -414,12 +435,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
room.PropertyChanged += onRoomPropertyChanged;
// subscribe to client P2P handshake status updates
p2pStatusHandler = s => Schedule(() =>
p2PStatusHandler = s => Schedule(() =>
{
P2PStatusText.Text = s;
P2PStatusText.FadeIn(100).Delay(2000).FadeOut(200);
});
client.P2PHandshakeStatusChanged += p2pStatusHandler;
client.P2PHandshakeStatusChanged += p2PStatusHandler;
updateRoomName();
updateRoomType();
@@ -455,10 +476,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
updateRoomAutoSkip();
break;
case nameof(Room.IsP2P):
updateRoomP2P();
break;
case nameof(Room.MaxParticipants):
updateRoomMaxParticipants();
break;
@@ -488,9 +505,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private void updateRoomAutoSkip()
=> AutoSkipCheckbox.Current.Value = room.AutoSkip;
private void updateRoomP2P()
=> P2PCheckbox.Current.Value = room.IsP2P;
private void updateRoomMaxParticipants()
=> MaxParticipantsField.Text = room.MaxParticipants?.ToString();
@@ -518,6 +532,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
Debug.Assert(applyingSettingsOperation == null);
applyingSettingsOperation = ongoingOperationTracker.BeginOperation();
// Check if P2P is enabled globally
bool p2PEnabled = ezConfig.Get<bool>(Ez2Setting.ExperimentalP2P);
// If the client is already in a room, update via the client.
// Otherwise, update the room directly in preparation for it to be submitted to the API on match creation.
if (client.Room != null)
@@ -545,7 +562,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
room.QueueMode = QueueModeDropdown.Current.Value;
room.AutoStartDuration = TimeSpan.FromSeconds((int)startModeDropdown.Current.Value);
room.AutoSkip = AutoSkipCheckbox.Current.Value;
room.IsP2P = P2PCheckbox.Current.Value;
// Force P2P mode when global P2P setting is enabled
room.IsP2P = p2PEnabled;
room.Playlist = drawablePlaylist.Items.ToArray();
client.CreateRoom(room).ContinueWith(t => Schedule(() =>
@@ -558,6 +576,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
}
}
#if DEBUG
private async void generateOffer()
{
try
@@ -600,9 +619,110 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
}
}
private void copySignallingLink()
{
ErrorText.FadeOut(50);
string payload = signallingBox.Text?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(payload))
{
Schedule(() =>
{
ErrorText.Text = "No signalling payload to copy";
ErrorText.FadeIn(100).Delay(1500).FadeOut(200);
});
return;
}
long roomId = client.Room?.RoomID ?? room.RoomID ?? 0;
int userId = api.LocalUser.Value?.Id ?? 0;
string role = client.IsHost ? "host-offer" : "peer-answer";
string link = $"ez2p2p://signal?room={roomId}&role={Uri.EscapeDataString(role)}&user={userId}&payload={Uri.EscapeDataString(payload)}";
clipboard.SetText(link);
Schedule(() =>
{
ErrorText.Text = "Signalling link copied to clipboard";
ErrorText.FadeIn(100).Delay(1500).FadeOut(200);
});
}
private void importSignallingFromClipboard()
{
ErrorText.FadeOut(50);
string raw = clipboard.GetText() ?? string.Empty;
if (string.IsNullOrWhiteSpace(raw))
{
Schedule(() =>
{
ErrorText.Text = "Clipboard is empty";
ErrorText.FadeIn(100).Delay(1500).FadeOut(200);
});
return;
}
if (tryExtractSignallingPayload(raw, out string payload))
{
signallingBox.Text = payload;
Schedule(() =>
{
ErrorText.Text = "Signalling link imported";
ErrorText.FadeIn(100).Delay(1500).FadeOut(200);
});
return;
}
signallingBox.Text = raw;
Schedule(() =>
{
ErrorText.Text = "Raw signalling payload pasted";
ErrorText.FadeIn(100).Delay(1500).FadeOut(200);
});
}
private static bool tryExtractSignallingPayload(string text, out string payload)
{
payload = string.Empty;
if (!Uri.TryCreate(text.Trim(), UriKind.Absolute, out Uri? uri))
return false;
if (!uri.Scheme.Equals("ez2p2p", StringComparison.OrdinalIgnoreCase))
return false;
if (!uri.Host.Equals("signal", StringComparison.OrdinalIgnoreCase))
return false;
string query = uri.Query;
if (query.StartsWith('?'))
query = query[1..];
foreach (string part in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
string[] keyValue = part.Split('=', 2);
if (keyValue.Length != 2)
continue;
if (!keyValue[0].Equals("payload", StringComparison.OrdinalIgnoreCase))
continue;
payload = Uri.UnescapeDataString(keyValue[1]);
return !string.IsNullOrWhiteSpace(payload);
}
return false;
}
private async void uploadHostSignalling()
{
ErrorText.FadeOut(50);
try
{
await client.UploadHostSignalling(signallingBox.Text);
@@ -621,6 +741,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private async void fetchHostSignalling()
{
ErrorText.FadeOut(50);
try
{
var sig = await client.GetHostSignalling();
@@ -644,9 +765,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private async void uploadPeerSignalling()
{
ErrorText.FadeOut(50);
try
{
var local = api.LocalUser.Value;
if (local == null)
{
Schedule(() =>
@@ -673,6 +796,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private async void fetchPeerAnswers()
{
ErrorText.FadeOut(50);
try
{
var dict = await client.GetPeerSignalling();
@@ -695,10 +819,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
}
}
#if DEBUG
private async void runDebugP2PTest()
{
ErrorText.FadeOut(50);
try
{
// Host flow: generate offer and upload
@@ -725,6 +849,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{
// Joiner flow: fetch host offer, create answer and upload
var hostSig = await client.GetHostSignalling();
if (string.IsNullOrEmpty(hostSig))
{
Schedule(() =>
@@ -744,6 +869,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
await webRtc.InitializeAsync();
var answer = await webRtc.CreateAnswerAsync(hostSig);
var local = api.LocalUser.Value;
if (local != null)
{
await client.UploadPeerSignalling(local.Id, answer);
@@ -788,6 +914,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
// see https://github.com/ppy/osu-web/blob/2c97aaeb64fb4ed97c747d8383a35b30f57428c7/app/Models/Multiplayer/PlaylistItem.php#L48.
const string not_found_prefix = "beatmaps not found:";
// Check if P2P is enabled
bool p2PEnabled = ezConfig.Get<bool>(Ez2Setting.ExperimentalP2P);
// In P2P mode, ignore beatmap availability errors from online API
if (p2PEnabled && message.StartsWith(not_found_prefix, StringComparison.Ordinal))
{
// Beatmap validation is bypassed in P2P mode - proceed regardless of online availability
onSuccess();
return;
}
if (message.StartsWith(not_found_prefix, StringComparison.Ordinal))
ErrorText.Text = "The selected beatmap is not available online.";
else
@@ -804,14 +941,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{
base.Dispose(isDisposing);
room.PropertyChanged -= onRoomPropertyChanged;
if (webRtcOwned && webRtc != null)
{
webRtc.Dispose();
webRtc = null;
webRtcOwned = false;
}
if (p2pStatusHandler != null)
client.P2PHandshakeStatusChanged -= p2pStatusHandler;
if (p2PStatusHandler != null)
client.P2PHandshakeStatusChanged -= p2PStatusHandler;
}
}

View File

@@ -98,7 +98,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
protected override void OpenNewRoom(Room room)
{
if (!client.IsConnected.Value)
if (!client.IsMultiplayerUsable.Value)
{
Logger.Log("Not currently connected to the multiplayer server.", LoggingTarget.Runtime, LogLevel.Important);
return;

View File

@@ -9,6 +9,7 @@ using osu.Framework.Bindables;
using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Game.Graphics.UserInterface;
using osu.Game.Beatmaps;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Screens.Select;
@@ -82,7 +83,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
ID = itemToEdit?.ID ?? 0,
BeatmapID = item.Beatmap.OnlineID,
BeatmapChecksum = item.Beatmap.MD5Hash,
BeatmapChecksum = getBestChecksum(item.Beatmap),
RulesetID = item.RulesetID,
RequiredMods = item.RequiredMods.ToArray(),
AllowedMods = item.AllowedMods.ToArray(),
@@ -120,6 +121,23 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
return true;
}
private static string getBestChecksum(IBeatmapInfo beatmap)
{
if (beatmap is BeatmapInfo localBeatmap)
{
if (!string.IsNullOrEmpty(localBeatmap.MD5Hash))
return localBeatmap.MD5Hash;
if (!string.IsNullOrEmpty(localBeatmap.OnlineMD5Hash))
return localBeatmap.OnlineMD5Hash;
if (!string.IsNullOrEmpty(localBeatmap.Hash))
return localBeatmap.Hash;
}
return beatmap.MD5Hash ?? string.Empty;
}
protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea();
}
}

View File

@@ -653,6 +653,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
// Update global gameplay state to correspond to the new selection.
// Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info
var localBeatmap = beatmapManager.QueryOnlineBeatmapId(gameplayBeatmapId);
if (localBeatmap == null && !string.IsNullOrEmpty(item.BeatmapChecksum))
{
string checksum = item.BeatmapChecksum;
localBeatmap = beatmapManager.QueryBeatmap(b => b.MD5Hash == checksum
|| b.OnlineMD5Hash == checksum
|| b.Hash == checksum);
}
Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap);
Ruleset.Value = ruleset;
Mods.Value = client.LocalUser.Mods.Concat(item.RequiredMods).Select(m => m.ToMod(rulesetInstance)).ToArray();
@@ -672,7 +682,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
userStyleSection.Show();
PlaylistItem apiItem = new PlaylistItem(item).With(beatmap: new Optional<IBeatmapInfo>(new APIBeatmap { OnlineID = gameplayBeatmapId }), ruleset: gameplayRulesetId);
IBeatmapInfo displayBeatmap = (IBeatmapInfo?)localBeatmap ?? new APIBeatmap
{
OnlineID = gameplayBeatmapId,
Checksum = item.BeatmapChecksum,
};
PlaylistItem apiItem = new PlaylistItem(item).With(beatmap: new Optional<IBeatmapInfo>(displayBeatmap), ruleset: gameplayRulesetId);
DrawableRoomPlaylistItem? currentDisplay = userStyleDisplayContainer.SingleOrDefault();
if (!apiItem.Equals(currentDisplay?.Item))
@@ -845,7 +861,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
if (ExitConfirmed)
return true;
if (api.State.Value != APIState.Online || !client.IsConnected.Value)
if (api.State.Value != APIState.Online || !client.IsMultiplayerUsable.Value)
return true;
if (dialogOverlay == null)

View File

@@ -128,7 +128,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
loadingDisplay.Show();
});
isConnected = client.IsConnected.GetBoundCopy();
isConnected = client.IsMultiplayerUsable.GetBoundCopy();
isConnected.BindValueChanged(connected => Schedule(() =>
{
if (!connected.NewValue)

View File

@@ -15,8 +15,10 @@ using osu.Framework.Threading;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
using Realms;
namespace osu.Game.Screens.OnlinePlay
{
@@ -44,6 +46,9 @@ namespace osu.Game.Screens.OnlinePlay
[Resolved]
private BeatmapLookupCache beatmapLookupCache { get; set; } = null!;
[Resolved]
private IAPIProvider api { get; set; } = null!;
private readonly Bindable<BeatmapAvailability> availability = new Bindable<BeatmapAvailability>(BeatmapAvailability.NotDownloaded());
private ScheduledDelegate? progressUpdate;
@@ -72,6 +77,12 @@ namespace osu.Game.Screens.OnlinePlay
cancelTracking();
if (api.IsLocalOnly)
{
startTracking(createLookupBeatmap(item.NewValue.Beatmap));
return;
}
beatmapLookupCache.GetBeatmapAsync(item.NewValue.Beatmap.OnlineID).ContinueWith(task => Schedule(() =>
{
var beatmap = task.GetResultSafely();
@@ -82,6 +93,23 @@ namespace osu.Game.Screens.OnlinePlay
}, true);
}
private static APIBeatmap createLookupBeatmap(IBeatmapInfo source)
{
int setId = source.BeatmapSet?.OnlineID ?? source.OnlineID;
return new APIBeatmap
{
OnlineID = source.OnlineID,
OnlineBeatmapSetID = setId,
Checksum = source.MD5Hash,
BeatmapSet = new APIBeatmapSet
{
OnlineID = setId,
Beatmaps = Array.Empty<APIBeatmap>(),
}
};
}
private void cancelTracking()
{
downloadTracker?.RemoveAndDisposeImmediately();
@@ -92,6 +120,20 @@ namespace osu.Game.Screens.OnlinePlay
{
Debug.Assert(beatmap.BeatmapSet != null);
if (api.IsLocalOnly)
{
realmSubscription = realm.RegisterForNotifications(_ => queryBeatmap(), (_, changes) =>
{
if (changes == null)
return;
Scheduler.AddOnce(updateLocalAvailability);
});
updateLocalAvailability();
return;
}
downloadTracker = new BeatmapDownloadTracker(beatmap.BeatmapSet);
downloadTracker.State.BindValueChanged(_ => Scheduler.AddOnce(updateAvailability), true);
downloadTracker.Progress.BindValueChanged(_ =>
@@ -153,8 +195,35 @@ namespace osu.Game.Screens.OnlinePlay
}
IQueryable<BeatmapInfo> queryBeatmap() =>
realm.Realm.All<BeatmapInfo>()
.ForOnlineId(beatmap.OnlineID);
queryBeatmapByLookup(beatmap);
void updateLocalAvailability()
=> availability.Value = queryBeatmap().Any()
? BeatmapAvailability.LocallyAvailable()
: BeatmapAvailability.NotDownloaded();
IQueryable<BeatmapInfo> queryBeatmapByLookup(APIBeatmap lookupBeatmap)
{
IQueryable<BeatmapInfo> allBeatmaps = realm.Realm.All<BeatmapInfo>().NotDeleted();
string checksum = lookupBeatmap.Checksum;
if (api.IsLocalOnly)
{
if (!string.IsNullOrEmpty(checksum))
return allBeatmaps.Filter($@"{nameof(BeatmapInfo.MD5Hash)} == $0 OR {nameof(BeatmapInfo.OnlineMD5Hash)} == $0 OR {nameof(BeatmapInfo.Hash)} == $0", checksum);
if (lookupBeatmap.OnlineID > 0)
return allBeatmaps.Filter($@"{nameof(BeatmapInfo.OnlineID)} == $0", lookupBeatmap.OnlineID);
if (lookupBeatmap.OnlineBeatmapSetID > 0)
return allBeatmaps.Filter($@"{nameof(BeatmapInfo.BeatmapSet)}.{nameof(BeatmapSetInfo.OnlineID)} == $0", lookupBeatmap.OnlineBeatmapSetID);
return allBeatmaps.Filter($@"{nameof(BeatmapInfo.OnlineID)} == $0", -1);
}
return allBeatmaps.ForOnlineId(lookupBeatmap.OnlineID);
}
}
protected override void Dispose(bool isDisposing)