forked from ppy/osu-server-spectator
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathRankedPlayMatchController.cs
More file actions
458 lines (377 loc) · 17.9 KB
/
Copy pathRankedPlayMatchController.cs
File metadata and controls
458 lines (377 loc) · 17.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
// 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.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Razor.TagHelpers;
using OpenSkillSharp.Models;
using OpenSkillSharp.Rating;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay;
using osu.Game.Online.RankedPlay;
using osu.Game.Online.Rooms;
using osu.Server.Spectator.Database;
using osu.Server.Spectator.Database.Models;
using osu.Server.Spectator.Hubs.Multiplayer.Matchmaking.Elo;
using osu.Server.Spectator.Hubs.Multiplayer.Matchmaking.Queue;
using osu.Server.Spectator.Hubs.Multiplayer.Matchmaking.RankedPlay.Stages;
namespace osu.Server.Spectator.Hubs.Multiplayer.Matchmaking.RankedPlay
{
[NonController]
public class RankedPlayMatchController : IMatchController, IMatchmakingMatchController
{
public const int PLAYER_HAND_SIZE = 5;
public const int DECK_SIZE = 50;
public MultiplayerPlaylistItem CurrentItem => Room.Playlist.Single(item => item.ID == Room.Settings.PlaylistItemId);
public IMatchmakingQueueBackgroundService MatchmakingService { get; private set; } = null!;
public matchmaking_pool Pool { get; private set; } = null!;
public bool Ranked { get; private set; }
public readonly ServerMultiplayerRoom Room;
public readonly IDatabaseFactory DbFactory;
public readonly MultiplayerEventDispatcher EventDispatcher;
public readonly RankedPlayRoomState State;
/// <summary>
/// The card that was last activated by any user.
/// </summary>
public RankedPlayCardItem? LastActivatedCard { get; private set; }
/// <summary>
/// The number of cards in the deck.
/// </summary>
public int DeckCount => deck.Count;
/// <summary>
/// The current stage implementation.
/// </summary>
public RankedPlayStageImplementation Stage { get; private set; }
/// <summary>
/// All users participating in this match ordered by their turn order.
/// </summary>
public int[] UserIdsByTurnOrder { get; private set; } = [];
public Dictionary<int, EloRating> RatingByUser { get; private set; } = [];
/// <summary>
/// Mapping of cards to their associated effect.
/// </summary>
private readonly Dictionary<RankedPlayCardItem, MultiplayerPlaylistItem> cardToEffectMap = [];
/// <summary>
/// Cards that may be drawn from the deck.
/// </summary>
private readonly List<RankedPlayCardItem> deck = [];
/// <summary>
/// Indicates whether the final user ratings have been updated.
/// Todo: This is public for testing purposes, but should not be.
/// </summary>
public bool UserRatingsUpdated { get; set; }
public RankedPlayMatchController(ServerMultiplayerRoom room, IDatabaseFactory dbFactory, MultiplayerEventDispatcher eventDispatcher)
{
Room = room;
DbFactory = dbFactory;
EventDispatcher = eventDispatcher;
State = new RankedPlayRoomState();
Stage = new EmptyStage(this);
room.MatchState = State;
}
async Task IMatchController.Initialise()
{
await EventDispatcher.PostMatchRoomStateChangedAsync(Room);
await GotoStage(RankedPlayStage.WaitForJoin);
}
async Task IMatchmakingMatchController.Initialise(matchmaking_pool pool, MatchmakingQueueUser[] users, MatchmakingBeatmapSelector beatmapSelector,
IMatchmakingQueueBackgroundService matchmakingService)
{
MatchmakingService = matchmakingService;
Pool = pool;
Ranked = pool.ranked;
// Build the deck.
matchmaking_pool_beatmap[] beatmaps = beatmapSelector.GetAppropriateBeatmaps(DECK_SIZE, users.Select(u => u.Rating).ToArray());
Random.Shared.Shuffle(beatmaps);
foreach (var beatmap in beatmaps)
{
var card = new RankedPlayCardItem();
cardToEffectMap[card] = beatmap.ToPlaylistItem();
deck.Add(card);
}
State.StarRating = beatmaps.Select(b => b.difficultyrating).DefaultIfEmpty(0).Average();
// Create an initial playlist item for the room. Clients require this to operate correctly.
using (var db = DbFactory.GetInstance())
{
MultiplayerPlaylistItem initialItem = new MultiplayerPlaylistItem();
initialItem.ID = await db.AddPlaylistItemAsync(new multiplayer_playlist_item(Room.RoomID, initialItem));
Room.Playlist.Add(initialItem);
Room.Settings.PlaylistItemId = initialItem.ID;
}
// Create the user states.
foreach (var user in users)
{
RatingByUser[user.UserId] = user.Rating;
State.Users[user.UserId] = new RankedPlayUserInfo
{
Rating = (int)Math.Round(user.Rating.Mu),
RatingAfter = (int)Math.Round(user.Rating.Mu)
};
}
UserIdsByTurnOrder = users
.OrderBy(u => u.Rating.Mu)
.ThenBy(_ => Random.Shared.NextSingle())
.Select(u => u.UserId)
.ToArray();
// Populate the initial active user, for use by the client to display the first turn's user.
State.ActiveUserId = UserIdsByTurnOrder[0];
await EventDispatcher.PostMatchRoomStateChangedAsync(Room);
}
Task<bool> IMatchController.UserCanJoin(int userId)
{
return Task.FromResult(State.Users.ContainsKey(userId));
}
Task IMatchController.HandleSettingsChanged()
{
return Task.CompletedTask;
}
async Task IMatchController.HandleGameplayCompleted()
{
using (var db = DbFactory.GetInstance())
{
await db.MarkPlaylistItemAsPlayedAsync(Room.RoomID, CurrentItem.ID);
multiplayer_playlist_item newItem = await db.GetPlaylistItemAsync(Room.RoomID, CurrentItem.ID);
CurrentItem.Expired = newItem.expired;
CurrentItem.PlayedAt = newItem.played_at;
await Room.HandlePlaylistItemChanged(CurrentItem, true);
}
await Stage.HandleGameplayCompleted();
}
async Task IMatchController.HandleUserRequest(MultiplayerRoomUser user, MatchUserRequest request)
{
switch (request)
{
case RankedPlayCardHandReplayRequest cardHandReplay:
await Stage.HandleCardHandReplayRequest(user, cardHandReplay);
await EventDispatcher.PostMatchEventAsync(Room.RoomID, new RankedPlayCardHandReplayEvent
{
UserId = user.UserID,
Frames = cardHandReplay.Frames
});
break;
}
}
async Task IMatchController.HandleUserJoined(MultiplayerRoomUser user)
{
await EventDispatcher.PostPlayerJoinedMatchmakingRoomAsync(Room.RoomID, user.UserID);
await Stage.HandleUserJoined(user);
}
async Task IMatchController.HandleUserLeft(MultiplayerRoomUser user)
{
await Stage.HandleUserLeft(user);
}
Task IMatchController.AddPlaylistItem(MultiplayerPlaylistItem item, MultiplayerRoomUser user)
{
return Task.CompletedTask;
}
Task IMatchController.EditPlaylistItem(MultiplayerPlaylistItem item, MultiplayerRoomUser user)
{
return Task.CompletedTask;
}
Task IMatchController.RemovePlaylistItem(long playlistItemId, MultiplayerRoomUser user)
{
return Task.CompletedTask;
}
async Task IMatchController.HandleUserStateChanged(MultiplayerRoomUser user)
{
await Stage.HandleUserStateChanged(user);
}
public void SkipToNextStage(out Task countdownTask)
{
if (!AppSettings.MatchmakingRoomAllowSkip)
throw new InvalidStateException("Skipping matchmaking rounds is not allowed.");
countdownTask = Room.SkipToEndOfCountdown(Room.FindCountdownOfType<RankedPlayStageCountdown>());
}
public async Task DiscardCards(MultiplayerRoomUser user, RankedPlayCardItem[] cards)
{
await Stage.HandleDiscardCards(user, cards);
}
public async Task PlayCard(MultiplayerRoomUser user, RankedPlayCardItem card)
{
await Stage.HandlePlayCard(user, card);
}
public async Task GotoStage(RankedPlayStage stage)
{
Stage = stage switch
{
RankedPlayStage.WaitForJoin => new WaitForJoinStage(this),
RankedPlayStage.RoundWarmup => new RoundWarmupStage(this),
RankedPlayStage.CardDiscard => new CardDiscardStage(this),
RankedPlayStage.FinishCardDiscard => new FinishCardDiscardStage(this),
RankedPlayStage.CardPlay => new CardPlayStage(this),
RankedPlayStage.FinishCardPlay => new FinishCardPlayStage(this),
RankedPlayStage.GameplayWarmup => new GameplayWarmupStage(this),
RankedPlayStage.Gameplay => new GameplayStage(this),
RankedPlayStage.Results => new ResultsStage(this),
RankedPlayStage.Ended => new EndedStage(this),
_ => throw new ArgumentOutOfRangeException(nameof(stage), stage, null)
};
await Stage.Enter();
}
/// <summary>
/// Draws a number of cards for a given user, placing them in their hand.
/// </summary>
/// <param name="userId">The user to draw cards for.</param>
/// <param name="count">The maximum number of cards to draw from the deck.</param>
public async Task AddCards(int userId, int count)
{
RankedPlayCardItem[] cards = deck.Take(count).ToArray();
deck.RemoveRange(0, cards.Length);
foreach (var card in cards)
{
State.Users[userId].Hand.Add(card);
await EventDispatcher.PostRankedPlayCardAdded(Room.RoomID, userId, card);
await EventDispatcher.PostRankedPlayCardRevealed(userId, card, cardToEffectMap[card]);
}
await EventDispatcher.PostMatchRoomStateChangedAsync(Room);
}
/// <summary>
/// Discards cards, removing them from a user's hand.
/// </summary>
/// <param name="userId">The user to discard cards from.</param>
/// <param name="cards">The cards to discard.</param>
public async Task RemoveCards(int userId, RankedPlayCardItem[] cards)
{
foreach (var card in cards)
{
State.Users[userId].Hand.Remove(card);
await EventDispatcher.PostRankedPlayCardRemoved(Room.RoomID, userId, card);
}
await EventDispatcher.PostMatchRoomStateChangedAsync(Room);
}
/// <summary>
/// Activates the card, placing its effect on the room.
/// </summary>
public async Task ActivateCard(RankedPlayCardItem card)
{
MultiplayerPlaylistItem effect = cardToEffectMap[card];
await EventDispatcher.PostRankedPlayCardRevealed(Room.RoomID, card, effect);
await EventDispatcher.PostRankedPlayCardPlayed(Room.RoomID, card);
// Todo: If we ever have cards with non-"play beatmap" effects, then
// this is the first responder to perform any relevant actions.
using (var db = DbFactory.GetInstance())
{
if (CurrentItem.Expired)
{
effect.ID = await db.AddPlaylistItemAsync(new multiplayer_playlist_item(Room.RoomID, effect));
Room.Playlist.Add(effect);
await EventDispatcher.PostPlaylistItemAddedAsync(Room.RoomID, effect);
}
else
{
effect.ID = CurrentItem.ID;
Room.Playlist[Room.Playlist.IndexOf(CurrentItem)] = effect;
await db.UpdatePlaylistItemAsync(new multiplayer_playlist_item(Room.RoomID, effect));
await Room.HandlePlaylistItemChanged(CurrentItem, true);
}
}
Room.Settings.PlaylistItemId = effect.ID;
await Room.HandleSettingsChanged(true);
LastActivatedCard = card;
}
/// <summary>
/// Causes a player to take damage.
/// </summary>
/// <param name="userId">The user ID of the player taking damage.</param>
/// <param name="amount">The amount of damage (before any multipliers are added) to take.</param>
/// <returns>A descriptor for the damage taken.</returns>
public RankedPlayDamageInfo Damage(int attackingUserId, int recievingUserId, int amount)
{
RankedPlayUserInfo recievingUserInfo = State.Users[recievingUserId];
RankedPlayUserInfo attackingUserInfo = State.Users[attackingUserId];
int rawDamage = amount;
int damage = (int)Math.Ceiling(rawDamage * State.GlobalMultiplier * attackingUserInfo.PersonalMultiplier);
int oldLife = recievingUserInfo.Life;
int newLife = Math.Max(0, oldLife - damage);
recievingUserInfo.Life = newLife;
return new RankedPlayDamageInfo
{
RawDamage = rawDamage,
Damage = damage,
OldLife = oldLife,
NewLife = newLife,
};
}
public async Task HandleMatchCompleted()
{
if (UserRatingsUpdated)
return;
UserRatingsUpdated = true;
// Forego any rating calculations if the match hasn't started yet.
// Naturally, this also means we don't have a winner to crown.
if (State.CurrentRound == 0)
{
await MatchmakingService.RecordMatch((int)Pool.id, State);
return;
}
int maxLife = State.Users.Max(u => u.Value.Life);
int[] winningUsers = State.Users.Where(u => u.Value.Life == maxLife).Select(u => u.Key).ToArray();
if (winningUsers.Length == 1)
State.WinningUserId = winningUsers.Single();
if (Ranked)
{
using (var db = DbFactory.GetInstance())
{
PlackettLuce model = new PlackettLuce
{
Mu = 1500,
Sigma = 150,
Beta = 75,
Tau = 1.5
};
List<matchmaking_user_stats> stats = [];
List<ITeam> teams = [];
List<double> scores = [];
foreach ((int userId, RankedPlayUserInfo user) in State.Users)
{
matchmaking_user_stats userStats = await db.GetMatchmakingUserStatsAsync(userId, Pool.id) ?? new matchmaking_user_stats
{
user_id = (uint)userId,
pool_id = Pool.id
};
stats.Add(userStats);
teams.Add(new Team { Players = [model.Rating(userStats.EloData.Rating.Mu, userStats.EloData.Rating.Sig)] });
scores.Add(user.Life);
}
IRating[] newRatings = model.Rate(teams, scores: scores).Select(t => t.Players.Single()).ToArray();
for (int i = 0; i < stats.Count; i++)
{
matchmaking_room_result result;
if (State.WinningUserId == null)
result = matchmaking_room_result.draw;
else if (State.WinningUserId == stats[i].user_id)
{
stats[i].first_placements++;
result = matchmaking_room_result.win;
}
else
result = matchmaking_room_result.loss;
await db.InsertUserEloHistoryEntry(
(ulong)Room.RoomID,
Pool.id,
stats[i].user_id,
stats.First(u => u.user_id != stats[i].user_id).user_id,
result,
(int)Math.Round(stats[i].EloData.Rating.Mu),
(int)Math.Round(newRatings[i].Mu));
stats[i].EloData.ContestCount++;
stats[i].EloData.Rating = new EloRating(newRatings[i].Mu, newRatings[i].Sigma);
await db.UpdateMatchmakingUserStatsAsync(stats[i]);
State.Users[(int)stats[i].user_id].RatingAfter = (int)Math.Round(newRatings[i].Mu);
}
}
}
await MatchmakingService.RecordMatch((int)Pool.id, State);
}
public MatchStartedEventDetail GetMatchDetails() => new MatchStartedEventDetail
{
room_type = database_match_type.ranked_play
};
/// <summary>
/// Retrieves the personal multiplier for a given player.
/// </summary>
/// <param name="winstreak">The winstreak of the given player.</param>
}
}