Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix AvatarAddrAndScoresWithRank edge case #31

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions ArenaService.Tests/ArenaService.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0"/>
<PackageReference Include="xunit" Version="2.5.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3"/>
</ItemGroup>

<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\ArenaService\ArenaService\ArenaService.csproj" />
</ItemGroup>

</Project>
130 changes: 130 additions & 0 deletions ArenaService.Tests/ArenaWorkerTest.cs

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions ArenaService.Tests/AvatarAddressAndScoreTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Libplanet.Crypto;

namespace ArenaService.Tests;

public class AvatarAddressAndScoreTest
{
[Fact]
public void Except_List()
{
var address = new PrivateKey().Address;
var address2 = new PrivateKey().Address;
var addressAndScore = new AvatarAddressAndScore(address, 100);
var addressAndScore2 = new AvatarAddressAndScore(address2, 100);
var updated = new AvatarAddressAndScore(address, 200);

// not equal because score is different
Assert.NotEqual(addressAndScore, updated);
// not equal because address is different
Assert.NotEqual(addressAndScore, addressAndScore2);

var prev = new List<AvatarAddressAndScore>
{
addressAndScore,
addressAndScore2,
};
var next = new List<AvatarAddressAndScore>
{
updated,
addressAndScore2
};
var excepted = Assert.Single(next.Except(prev));
Assert.Equal(updated.AvatarAddr, excepted.AvatarAddr);
Assert.Equal(updated.Score, excepted.Score);
Assert.Equal(updated, excepted);
}
}
6 changes: 6 additions & 0 deletions ArenaService/ArenaService.sln
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArenaService", "ArenaServic
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NineChronicles.RPC.Shared", "..\NineChronicles.RPC.Shared\NineChronicles.RPC.Shared\NineChronicles.RPC.Shared.csproj", "{C936C300-10D8-4A70-8815-67F16CEC6A0A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArenaService.Tests", "..\ArenaService.Tests\ArenaService.Tests.csproj", "{B8F66D71-6DAB-4CF3-B5DA-E35FAAB393F9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -18,5 +20,9 @@ Global
{C936C300-10D8-4A70-8815-67F16CEC6A0A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C936C300-10D8-4A70-8815-67F16CEC6A0A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C936C300-10D8-4A70-8815-67F16CEC6A0A}.Release|Any CPU.Build.0 = Release|Any CPU
{B8F66D71-6DAB-4CF3-B5DA-E35FAAB393F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B8F66D71-6DAB-4CF3-B5DA-E35FAAB393F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B8F66D71-6DAB-4CF3-B5DA-E35FAAB393F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B8F66D71-6DAB-4CF3-B5DA-E35FAAB393F9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
using Libplanet.Crypto;
using Nekoyume.Model.State;

namespace ArenaService;

public struct ArenaParticipant
public struct ArenaParticipantStruct
{
public Address AvatarAddr { get; set; }
public int Score { get; set; }
Expand All @@ -15,11 +14,11 @@ public struct ArenaParticipant
public string NameWithHash { get; set; } = "";
public int Level { get; set; }

public ArenaParticipant()
public ArenaParticipantStruct()
{
}

public ArenaParticipant(
public ArenaParticipantStruct(
Address avatarAddr,
int score,
int rank,
Expand Down
20 changes: 10 additions & 10 deletions ArenaService/ArenaService/ArenaParticipantType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,44 @@

namespace ArenaService;

public class ArenaParticipantType : ObjectGraphType<ArenaParticipant>
public class ArenaParticipantType : ObjectGraphType<ArenaParticipantStruct>
{
public ArenaParticipantType()
{
Field<NonNullGraphType<AddressType>>(
nameof(ArenaParticipant.AvatarAddr),
nameof(ArenaParticipantStruct.AvatarAddr),
description: "Address of avatar.",
resolve: context => context.Source.AvatarAddr);
Field<NonNullGraphType<IntGraphType>>(
nameof(ArenaParticipant.Score),
nameof(ArenaParticipantStruct.Score),
description: "Arena score of avatar.",
resolve: context => context.Source.Score);
Field<NonNullGraphType<IntGraphType>>(
nameof(ArenaParticipant.Rank),
nameof(ArenaParticipantStruct.Rank),
description: "Arena rank of avatar.",
resolve: context => context.Source.Rank);
Field<NonNullGraphType<IntGraphType>>(
nameof(ArenaParticipant.WinScore),
nameof(ArenaParticipantStruct.WinScore),
description: "Score for victory.",
resolve: context => context.Source.WinScore);
Field<NonNullGraphType<IntGraphType>>(
nameof(ArenaParticipant.LoseScore),
nameof(ArenaParticipantStruct.LoseScore),
description: "Score for defeat.",
resolve: context => context.Source.LoseScore);
Field<NonNullGraphType<IntGraphType>>(
nameof(ArenaParticipant.Cp),
nameof(ArenaParticipantStruct.Cp),
description: "Cp of avatar.",
resolve: context => context.Source.Cp);
Field<NonNullGraphType<IntGraphType>>(
nameof(ArenaParticipant.PortraitId),
nameof(ArenaParticipantStruct.PortraitId),
description: "Portrait icon id.",
resolve: context => context.Source.PortraitId);
Field<NonNullGraphType<IntGraphType>>(
nameof(ArenaParticipant.Level),
nameof(ArenaParticipantStruct.Level),
description: "Level of avatar.",
resolve: context => context.Source.Level);
Field<NonNullGraphType<StringGraphType>>(
nameof(ArenaParticipant.NameWithHash),
nameof(ArenaParticipantStruct.NameWithHash),
description: "Name of avatar.",
resolve: context => context.Source.NameWithHash);
}
Expand Down
69 changes: 63 additions & 6 deletions ArenaService/ArenaService/ArenaWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,20 @@ public async Task PrepareArenaParticipants()
}

var tip = _rpcClient.Tip!;
var blockIndex = tip.Index;
var currentRoundData = await _rpcClient.GetRoundData(tip, cancellationToken);
var participants = await _rpcClient.GetArenaParticipantsState(tip, currentRoundData, cancellationToken);
var championshipId = currentRoundData.ChampionshipId;
var round = currentRoundData.Round;
var cacheKey = $"{currentRoundData.ChampionshipId}_{currentRoundData.Round}";
var scoreCacheKey = $"{cacheKey}_scores";
var prevAddrAndScores = await _service.GetAvatarAddrAndScores(scoreCacheKey);
var prevArenaParticipants = await _service.GetArenaParticipantsAsync(cacheKey);
var expiry = TimeSpan.FromMinutes(5);
if (participants is null)
{
await _service.SetArenaParticipantsAsync(cacheKey, new List<ArenaParticipant>(), expiry);
_logger.LogInformation("[ArenaParticipantsWorker] participants({CacheKey}) is null. set empty list", cacheKey);
await _service.SetArenaParticipantsAsync(cacheKey, new List<ArenaParticipantStruct>(), expiry);
_logger.LogInformation("[ArenaParticipantsWorker] participants({CacheKey}) is null. set empty list on {BlockIndex}", cacheKey, blockIndex);
return;
}

Expand All @@ -79,14 +82,68 @@ public async Task PrepareArenaParticipants()
// 이전상태의 아바타 주소, 점수를 비교해서 추가되거나 점수가 변경된 대상만 찾음
var updatedAddressAndScores = avatarAddrAndScores.Except(prevAddrAndScores).ToList();
// 전체목록의 랭킹 순서 처리
var avatarAddrAndScoresWithRank = _rpcClient.AvatarAddrAndScoresWithRank(avatarAddrAndScores);
var avatarAddrAndScoresWithRank = AvatarAddrAndScoresWithRank(avatarAddrAndScores);
// 전체목록의 ArenaParticipant 업데이트
var result = await _rpcClient.GetArenaParticipants(tip, updatedAddressAndScores.Select(i => i.AvatarAddr).ToList(), avatarAddrAndScoresWithRank, prevArenaParticipants, cancellationToken);
var tuple = await _rpcClient.GetArenaParticipants(tip, championshipId, round, updatedAddressAndScores.Select(i => i.AvatarAddr).ToList(), avatarAddrAndScoresWithRank, prevArenaParticipants, cancellationToken);
// 캐시 업데이트
await _service.SetArenaParticipantsAsync(cacheKey, result, expiry);
await _service.SetArenaParticipantsAsync(cacheKey, tuple.Item1, expiry);
await _service.SetSeasonAsync(cacheKey, expiry);
await _service.SetAvatarAddrAndScores(scoreCacheKey, avatarAddrAndScores, expiry);
sw.Stop();
_logger.LogInformation("[ArenaParticipantsWorker]Set Arena Cache[{CacheKey}]: {Elapsed}", cacheKey, sw.Elapsed);
_logger.LogInformation("[ArenaParticipantsWorker]Set Arena Cache[{CacheKey}] on {BlockIndex}/{LatestBattleBlockIndex}: {Elapsed}", cacheKey, blockIndex, tuple.Item2, sw.Elapsed);
}


/// <summary>
/// Retrieves the avatar addresses and scores with ranks for a given list of avatar addresses, current round data, and world state.
/// </summary>
/// <param name="avatarAddrAndScores">Ths list of avatar address and score tuples.</param>
/// <returns>The list of avatar addresses, scores, and ranks.</returns>
public static List<ArenaScoreAndRank> AvatarAddrAndScoresWithRank(List<AvatarAddressAndScore> avatarAddrAndScores)
{
if (avatarAddrAndScores.Count == 0)
{
return new List<ArenaScoreAndRank>();
}

if (avatarAddrAndScores.Count == 1)
{
var score = avatarAddrAndScores.Single();
return [new ArenaScoreAndRank(score.AvatarAddr, score.Score, 1)];
}

var orderedTuples = avatarAddrAndScores
.OrderByDescending(tuple => tuple.Score)
.ThenBy(tuple => tuple.AvatarAddr)
.ToList();

var avatarAddrAndScoresWithRank = new List<ArenaScoreAndRank>();
while (orderedTuples.Count > 0)
{
// 동점자를 찾기위해 기준 점수 설정
var currentScore = orderedTuples.First().Score;
var groupSize = 0;
var targets = new List<AvatarAddressAndScore>();
foreach (var tuple in orderedTuples)
{
if (currentScore == tuple.Score)
{
groupSize++;
targets.Add(tuple);
}
else
{
break;
}
}

// 순위는 기존 상위권 순위 + 동점자의 숫자
var rank = avatarAddrAndScoresWithRank.Count + groupSize;
avatarAddrAndScoresWithRank.AddRange(targets.Select(tuple => new ArenaScoreAndRank(tuple.AvatarAddr, tuple.Score, rank)));
// 다음 순위 설정을 위해 이번 그룹 숫자만큼 삭제
orderedTuples.RemoveRange(0, groupSize);
}

return avatarAddrAndScoresWithRank;
}
}
4 changes: 2 additions & 2 deletions ArenaService/ArenaService/IRedisArenaParticipantsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ namespace ArenaService;

public interface IRedisArenaParticipantsService
{
Task<List<ArenaParticipant>> GetArenaParticipantsAsync(string key);
Task SetArenaParticipantsAsync(string key, List<ArenaParticipant> value, TimeSpan? expiry = null);
Task<List<ArenaParticipantStruct>> GetArenaParticipantsAsync(string key);
Task SetArenaParticipantsAsync(string key, List<ArenaParticipantStruct> value, TimeSpan? expiry = null);
Task<string> GetSeasonKeyAsync();
Task SetSeasonAsync(string value, TimeSpan? expiry = null);
Task<List<AvatarAddressAndScore>> GetAvatarAddrAndScores(string key);
Expand Down
8 changes: 4 additions & 4 deletions ArenaService/ArenaService/RedisArenaParticipantsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,18 @@ public class RedisArenaParticipantsService(IConnectionMultiplexer redis)

private readonly IDatabase _db = redis.GetDatabase();

public async Task<List<ArenaParticipant>> GetArenaParticipantsAsync(string key)
public async Task<List<ArenaParticipantStruct>> GetArenaParticipantsAsync(string key)
{
RedisValue result = await _db.StringGetAsync(key);
if (result.IsNull)
{
return new List<ArenaParticipant>();
return new List<ArenaParticipantStruct>();
}

return JsonSerializer.Deserialize<List<ArenaParticipant>>(result.ToString())!;
return JsonSerializer.Deserialize<List<ArenaParticipantStruct>>(result.ToString())!;
}

public async Task SetArenaParticipantsAsync(string key, List<ArenaParticipant> value, TimeSpan? expiry = null)
public async Task SetArenaParticipantsAsync(string key, List<ArenaParticipantStruct> value, TimeSpan? expiry = null)
{
var serialized = JsonSerializer.Serialize(value);
await _db.StringSetAsync(key, serialized, expiry);
Expand Down
Loading
Loading