mirror of
https://github.com/SK-la/Ez2Lazer.git
synced 2026-03-13 11:20:28 +00:00
主机创建房间,可进入游戏
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
337
osu.Game/Online/LocalMultiplayer/LocalMultiplayerDirectBridge.cs
Normal file
337
osu.Game/Online/LocalMultiplayer/LocalMultiplayerDirectBridge.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user