// See the LICENCE file in the repository root for full licence text.
#nullable enable
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.RoomStatuses;
using osu.Game.Rulesets;
using osu.Game.Users;
using osu.Game.Utils;
namespace osu.Game.Online.RealtimeMultiplayer
{
public abstract class StatefulMultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer
{
///
/// Invoked when any change occurs to the multiplayer room.
///
public event Action? RoomChanged;
///
/// Invoked when the multiplayer server requests the current beatmap to be loaded into play.
///
public event Action? LoadRequested;
///
/// Invoked when the multiplayer server requests gameplay to be started.
///
public event Action? MatchStarted;
///
/// Invoked when the multiplayer server has finished collating results.
///
public event Action? ResultsReady;
///
/// Whether the is currently connected.
///
public abstract IBindable IsConnected { get; }
///
/// The joined .
///
public MultiplayerRoom? Room { get; private set; }
///
/// The users currently in gameplay.
///
public readonly BindableList PlayingUsers = new BindableList();
[Resolved]
private UserLookupCache userLookupCache { get; set; } = null!;
[Resolved]
private IAPIProvider api { get; set; } = null!;
[Resolved]
private RulesetStore rulesets { get; set; } = null!;
private Room? apiRoom;
// Todo: This is temporary, until the multiplayer server returns the item id on match start or otherwise.
private int playlistItemId;
protected StatefulMultiplayerClient()
{
IsConnected.BindValueChanged(connected =>
{
// clean up local room state on server disconnect.
if (!connected.NewValue)
{
Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important);
LeaveRoom().FireAndForget();
}
});
}
///
/// Joins the for a given API .
///
/// The API .
public async Task JoinRoom(Room room)
{
if (Room != null)
throw new InvalidOperationException("Cannot join a multiplayer room while already in one.");
Debug.Assert(room.RoomID.Value != null);
apiRoom = room;
playlistItemId = room.Playlist.SingleOrDefault()?.ID ?? 0;
Room = await JoinRoom(room.RoomID.Value.Value);
Debug.Assert(Room != null);
foreach (var user in Room.Users)
await PopulateUser(user);
updateLocalRoomSettings(Room.Settings);
}
///
/// Joins the with a given ID.
///
/// The room ID.
/// The joined .
protected abstract Task JoinRoom(long roomId);
public virtual Task LeaveRoom()
{
if (Room == null)
return Task.CompletedTask;
apiRoom = null;
Room = null;
Schedule(() => RoomChanged?.Invoke());
return Task.CompletedTask;
}
///
/// Change the current settings.
///
///
/// A room must be joined for this to have any effect.
///
/// The new room name, if any.
/// The new room playlist item, if any.
public Task ChangeSettings(Optional name = default, Optional item = default)
{
if (Room == null)
throw new InvalidOperationException("Must be joined to a match to change settings.");
// A dummy playlist item filled with the current room settings (except mods).
var existingPlaylistItem = new PlaylistItem
{
Beatmap =
{
Value = new BeatmapInfo
{
OnlineBeatmapID = Room.Settings.BeatmapID,
MD5Hash = Room.Settings.BeatmapChecksum
}
},
RulesetID = Room.Settings.RulesetID
};
return ChangeSettings(new MultiplayerRoomSettings
{
Name = name.GetOr(Room.Settings.Name),
BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID,
BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash,
RulesetID = item.GetOr(existingPlaylistItem).RulesetID,
Mods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.Mods
});
}
public abstract Task TransferHost(int userId);
public abstract Task ChangeSettings(MultiplayerRoomSettings settings);
public abstract Task ChangeState(MultiplayerUserState newState);
public abstract Task StartMatch();
Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state)
{
if (Room == null)
return Task.CompletedTask;
Schedule(() =>
{
if (Room == null)
return;
Debug.Assert(apiRoom != null);
Room.State = state;
switch (state)
{
case MultiplayerRoomState.Open:
apiRoom.Status.Value = new RoomStatusOpen();
break;
case MultiplayerRoomState.Playing:
apiRoom.Status.Value = new RoomStatusPlaying();
break;
case MultiplayerRoomState.Closed:
apiRoom.Status.Value = new RoomStatusEnded();
break;
}
RoomChanged?.Invoke();
});
return Task.CompletedTask;
}
async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user)
{
if (Room == null)
return;
await PopulateUser(user);
Schedule(() =>
{
if (Room == null)
return;
Room.Users.Add(user);
RoomChanged?.Invoke();
});
}
Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user)
{
if (Room == null)
return Task.CompletedTask;
Schedule(() =>
{
if (Room == null)
return;
Room.Users.Remove(user);
PlayingUsers.Remove(user.UserID);
RoomChanged?.Invoke();
});
return Task.CompletedTask;
}
Task IMultiplayerClient.HostChanged(int userId)
{
if (Room == null)
return Task.CompletedTask;
Schedule(() =>
{
if (Room == null)
return;
Debug.Assert(apiRoom != null);
var user = Room.Users.FirstOrDefault(u => u.UserID == userId);
Room.Host = user;
apiRoom.Host.Value = user?.User;
RoomChanged?.Invoke();
});
return Task.CompletedTask;
}
Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings)
{
updateLocalRoomSettings(newSettings);
return Task.CompletedTask;
}
Task IMultiplayerClient.UserStateChanged(int userId, MultiplayerUserState state)
{
if (Room == null)
return Task.CompletedTask;
Schedule(() =>
{
if (Room == null)
return;
Room.Users.Single(u => u.UserID == userId).State = state;
if (state != MultiplayerUserState.Playing)
PlayingUsers.Remove(userId);
RoomChanged?.Invoke();
});
return Task.CompletedTask;
}
Task IMultiplayerClient.LoadRequested()
{
if (Room == null)
return Task.CompletedTask;
Schedule(() =>
{
if (Room == null)
return;
LoadRequested?.Invoke();
});
return Task.CompletedTask;
}
Task IMultiplayerClient.MatchStarted()
{
if (Room == null)
return Task.CompletedTask;
Schedule(() =>
{
if (Room == null)
return;
PlayingUsers.AddRange(Room.Users.Where(u => u.State == MultiplayerUserState.Playing).Select(u => u.UserID));
MatchStarted?.Invoke();
});
return Task.CompletedTask;
}
Task IMultiplayerClient.ResultsReady()
{
if (Room == null)
return Task.CompletedTask;
Schedule(() =>
{
if (Room == null)
return;
ResultsReady?.Invoke();
});
return Task.CompletedTask;
}
///
/// Populates the for a given .
///
/// The to populate.
protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID);
///
/// Updates the local room settings with the given .
///
///
/// This updates both the joined and the respective API .
///
/// The new to update from.
private void updateLocalRoomSettings(MultiplayerRoomSettings settings)
{
if (Room == null)
return;
// Update a few properties of the room instantaneously.
Schedule(() =>
{
if (Room == null)
return;
Debug.Assert(apiRoom != null);
Room.Settings = settings;
apiRoom.Name.Value = Room.Settings.Name;
// The playlist update is delayed until an online beatmap lookup (below) succeeds.
// In-order for the client to not display an outdated beatmap, the playlist is forcefully cleared here.
apiRoom.Playlist.Clear();
RoomChanged?.Invoke();
});
var req = new GetBeatmapSetRequest(settings.BeatmapID, BeatmapSetLookupType.BeatmapId);
req.Success += res => updatePlaylist(settings, res);
api.Queue(req);
}
private void updatePlaylist(MultiplayerRoomSettings settings, APIBeatmapSet onlineSet)
{
if (Room == null || !Room.Settings.Equals(settings))
return;
Debug.Assert(apiRoom != null);
var beatmapSet = onlineSet.ToBeatmapSet(rulesets);
var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID);
beatmap.MD5Hash = settings.BeatmapChecksum;
var ruleset = rulesets.GetRuleset(settings.RulesetID).CreateInstance();
var mods = settings.Mods.Select(m => m.ToMod(ruleset));
PlaylistItem playlistItem = new PlaylistItem
{
ID = playlistItemId,
Beatmap = { Value = beatmap },
Ruleset = { Value = ruleset.RulesetInfo },
};
playlistItem.RequiredMods.AddRange(mods);
apiRoom.Playlist.Clear(); // Clearing should be unnecessary, but here for sanity.
apiRoom.Playlist.Add(playlistItem);
}
}
}