Skip to content

Commit 4ffb0ed

Browse files
authored
Merge pull request #44 from Atralupus/feat/ranking
Implement ranking system with redis sortedset
2 parents 7d8acbf + 9a0732c commit 4ffb0ed

File tree

9 files changed

+169
-37
lines changed

9 files changed

+169
-37
lines changed

ArenaService/ArenaService.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
1515
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0" />
1616
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
17+
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
1718
<PackageReference Include="StrawberryShake.Server" Version="14.3.0" />
1819
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
1920
</ItemGroup>

ArenaService/Controllers/BattleController.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ public async Task<
8181
[ProducesResponseType(typeof(string), StatusCodes.Status200OK)]
8282
[ProducesResponseType(typeof(UnauthorizedHttpResult), StatusCodes.Status401Unauthorized)]
8383
[ProducesResponseType(typeof(NotFound<string>), StatusCodes.Status404NotFound)]
84-
public Results<UnauthorizedHttpResult, NotFound<string>, Ok<string>> ResultBattle(
84+
public Results<UnauthorizedHttpResult, NotFound<string>, Ok<string>> RequestBattle(
8585
string txId,
8686
int logId
8787
)

ArenaService/Controllers/LeaderboardController.cs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,17 @@ public class LeaderboardController : ControllerBase
1212
{
1313
private readonly ILeaderboardRepository _leaderboardRepo;
1414
private readonly IParticipantRepository _participantRepo;
15+
private readonly IRankingRepository _rankingRepository;
1516

1617
public LeaderboardController(
1718
ILeaderboardRepository leaderboardRepo,
18-
IParticipantRepository participantRepo
19+
IParticipantRepository participantRepo,
20+
IRankingRepository rankingRepository
1921
)
2022
{
2123
_leaderboardRepo = leaderboardRepo;
2224
_participantRepo = participantRepo;
25+
_rankingRepository = rankingRepository;
2326
}
2427

2528
// [HttpGet]
@@ -51,15 +54,25 @@ string avatarAddress
5154
return TypedResults.NotFound("Not participant user.");
5255
}
5356

54-
var leaderboardEntry = await _leaderboardRepo.GetMyRankAsync(seasonId, participant.Id);
57+
var rankingKey = $"ranking:season:{seasonId}";
5558

56-
if (leaderboardEntry == null)
59+
var rank = await _rankingRepository.GetRankAsync(rankingKey, participant.Id);
60+
var score = await _rankingRepository.GetScoreAsync(rankingKey, participant.Id);
61+
62+
if (rank is null || score is null)
5763
{
58-
return TypedResults.NotFound("No leaderboardEntry found.");
64+
return TypedResults.NotFound("Not participant user.");
5965
}
6066

61-
leaderboardEntry.Participant = participant;
62-
63-
return TypedResults.Ok(leaderboardEntry.ToResponse());
67+
return TypedResults.Ok(
68+
new LeaderboardEntryResponse
69+
{
70+
AvatarAddress = participant.AvatarAddress,
71+
NameWithHash = participant.NameWithHash,
72+
PortraitId = participant.PortraitId,
73+
Rank = rank.Value,
74+
TotalScore = score.Value,
75+
}
76+
);
6477
}
6578
}

ArenaService/Extensions/LeaderboardExtensions.cs

Lines changed: 0 additions & 24 deletions
This file was deleted.

ArenaService/Models/LeaderboardEntry.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
namespace ArenaService.Models;
55

66
[Table("leaderboard")]
7+
// score board
78
public class LeaderboardEntry
89
{
910
public int Id { get; set; }
@@ -16,7 +17,5 @@ public class LeaderboardEntry
1617
public int SeasonId { get; set; }
1718
public Season Season { get; set; } = null!;
1819

19-
[Required]
20-
public int Rank { get; set; }
2120
public int TotalScore { get; set; } = 1000;
2221
}

ArenaService/Options/RedisOptions.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,7 @@ public class RedisOptions
55
public const string SectionName = "Redis";
66
public string Host { get; set; } = "127.0.0.1";
77
public string Port { get; set; } = "6379";
8-
public string Prefix { get; set; } = "arena_hangfire:";
8+
public string HangfirePrefix { get; set; } = "arena_hangfire:";
9+
public string HangfireDbNumber { get; set; } = "0";
10+
public string RankingDbNumber { get; set; } = "1";
911
}

ArenaService/Processor/CalcAvailableOpponentsProcessor.cs

Whitespace-only changes.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
using StackExchange.Redis;
2+
3+
namespace ArenaService.Repositories;
4+
5+
public interface IRankingRepository
6+
{
7+
Task UpdateScoreAsync(string leaderboardKey, int participantId, int scoreChange);
8+
9+
Task<int?> GetRankAsync(string leaderboardKey, int participantId);
10+
11+
Task<int?> GetScoreAsync(string leaderboardKey, int participantId);
12+
13+
Task<List<(int Rank, int ParticipantId, int Score)>> GetTopRankingsAsync(
14+
string leaderboardKey,
15+
int topN
16+
);
17+
18+
Task<List<(int Rank, int ParticipantId, int Score)>> GetRankingsWithPaginationAsync(
19+
string leaderboardKey,
20+
int pageNumber,
21+
int pageSize
22+
);
23+
24+
Task SyncLeaderboardAsync(string leaderboardKey, List<(int ParticipantId, int Score)> entries);
25+
}
26+
27+
public class RankingRepository : IRankingRepository
28+
{
29+
private readonly IDatabase _redis;
30+
31+
public RankingRepository(IConnectionMultiplexer redis)
32+
{
33+
_redis = redis.GetDatabase();
34+
}
35+
36+
public async Task UpdateScoreAsync(string leaderboardKey, int participantId, int scoreChange)
37+
{
38+
await _redis.SortedSetIncrementAsync(
39+
leaderboardKey,
40+
$"participant:{participantId}",
41+
scoreChange
42+
);
43+
}
44+
45+
public async Task<int?> GetRankAsync(string leaderboardKey, int participantId)
46+
{
47+
var rank = await _redis.SortedSetRankAsync(
48+
leaderboardKey,
49+
$"participant:{participantId}",
50+
Order.Descending
51+
);
52+
return rank.HasValue ? (int)rank.Value + 1 : null;
53+
}
54+
55+
public async Task<int?> GetScoreAsync(string leaderboardKey, int participantId)
56+
{
57+
var score = await _redis.SortedSetScoreAsync(
58+
leaderboardKey,
59+
$"participant:{participantId}"
60+
);
61+
return score.HasValue ? (int)score.Value : null;
62+
}
63+
64+
public async Task<List<(int Rank, int ParticipantId, int Score)>> GetTopRankingsAsync(
65+
string leaderboardKey,
66+
int topN
67+
)
68+
{
69+
var topRankings = await _redis.SortedSetRangeByRankWithScoresAsync(
70+
leaderboardKey,
71+
0,
72+
topN - 1,
73+
Order.Descending
74+
);
75+
76+
return topRankings
77+
.Select(
78+
(entry, index) =>
79+
{
80+
var participantId = int.Parse(entry.Element.ToString().Split(':')[1]);
81+
return (Rank: index + 1, ParticipantId: participantId, Score: (int)entry.Score);
82+
}
83+
)
84+
.ToList();
85+
}
86+
87+
public async Task<
88+
List<(int Rank, int ParticipantId, int Score)>
89+
> GetRankingsWithPaginationAsync(string leaderboardKey, int pageNumber, int pageSize)
90+
{
91+
int start = (pageNumber - 1) * pageSize;
92+
int end = start + pageSize - 1;
93+
94+
var rankedParticipants = await _redis.SortedSetRangeByRankWithScoresAsync(
95+
leaderboardKey,
96+
start,
97+
end,
98+
Order.Descending
99+
);
100+
101+
return rankedParticipants
102+
.Select(
103+
(entry, index) =>
104+
{
105+
var participantId = int.Parse(entry.Element.ToString().Split(':')[1]);
106+
return (
107+
Rank: start + index + 1,
108+
ParticipantId: participantId,
109+
Score: (int)entry.Score
110+
);
111+
}
112+
)
113+
.ToList();
114+
}
115+
116+
public async Task SyncLeaderboardAsync(
117+
string leaderboardKey,
118+
List<(int ParticipantId, int Score)> entries
119+
)
120+
{
121+
foreach (var entry in entries)
122+
{
123+
await _redis.SortedSetAddAsync(
124+
leaderboardKey,
125+
$"participant:{entry.ParticipantId}",
126+
entry.Score
127+
);
128+
}
129+
}
130+
}

ArenaService/Setup.cs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ namespace ArenaService;
1010
using Microsoft.EntityFrameworkCore;
1111
using Microsoft.Extensions.Options;
1212
using Microsoft.OpenApi.Models;
13+
using StackExchange.Redis;
1314

1415
public class Startup
1516
{
@@ -68,6 +69,15 @@ public void ConfigureServices(IServiceCollection services)
6869
.UseNpgsql(Configuration.GetConnectionString("DefaultConnection"))
6970
.UseSnakeCaseNamingConvention()
7071
);
72+
73+
services.AddSingleton<IConnectionMultiplexer>(provider =>
74+
{
75+
var redisOptions = provider.GetRequiredService<IOptions<RedisOptions>>().Value;
76+
return ConnectionMultiplexer.Connect(
77+
$"{redisOptions.Host}:{redisOptions.Port},defaultDatabase={redisOptions.RankingDbNumber}"
78+
);
79+
});
80+
7181
services.AddEndpointsApiExplorer();
7282

7383
services.AddSwaggerGen(options =>
@@ -94,6 +104,7 @@ public void ConfigureServices(IServiceCollection services)
94104
options.OperationFilter<AuthorizeCheckOperationFilter>();
95105
});
96106

107+
services.AddScoped<IRankingRepository, RankingRepository>();
97108
services.AddScoped<ISeasonRepository, SeasonRepository>();
98109
services.AddScoped<IParticipantRepository, ParticipantRepository>();
99110
services.AddScoped<IAvailableOpponentRepository, AvailableOpponentRepository>();
@@ -113,8 +124,8 @@ public void ConfigureServices(IServiceCollection services)
113124
{
114125
var redisOptions = provider.GetRequiredService<IOptions<RedisOptions>>().Value;
115126
config.UseRedisStorage(
116-
$"{redisOptions.Host}:{redisOptions.Port}",
117-
new RedisStorageOptions { Prefix = redisOptions.Prefix }
127+
$"{redisOptions.Host}:{redisOptions.Port},defaultDatabase={redisOptions.HangfireDbNumber}",
128+
new RedisStorageOptions { Prefix = redisOptions.HangfirePrefix }
118129
);
119130
}
120131
);

0 commit comments

Comments
 (0)