diff --git a/Substrate.Hexalem.Integration.Test/HexalemTests.cs b/Substrate.Hexalem.Integration.Test/HexalemTests.cs index c673b4b..20158dc 100644 --- a/Substrate.Hexalem.Integration.Test/HexalemTests.cs +++ b/Substrate.Hexalem.Integration.Test/HexalemTests.cs @@ -1,37 +1,14 @@ using Substrate.Integration; using Substrate.Integration.Client; using Substrate.NET.Schnorrkel.Keys; +using Substrate.NET.Wallet.Keyring; using Substrate.NetApi; using Substrate.NetApi.Model.Types; namespace Substrate.Hexalem.Integration.Test { - public class HexalemTest + public class HexalemTest : IntegrationCommonTests { - public MiniSecret MiniSecretAlice => new MiniSecret(Utils.HexToByteArray("0xe5be9a5092b81bca64be81d212e7f2f9eba183bb7a90954f7b76361f6edb5c0a"), ExpandMode.Ed25519); - public Account Alice => Account.Build(KeyType.Sr25519, MiniSecretAlice.ExpandToSecret().ToEd25519Bytes(), MiniSecretAlice.GetPair().Public.Key); - - public MiniSecret MiniSecretBob => new MiniSecret(Utils.HexToByteArray("0x398f0c28f98885e046333d4a41c19cee4c37368a9832c6502f6cfd182e2aef89"), ExpandMode.Ed25519); - public Account Bob => Account.Build(KeyType.Sr25519, MiniSecretBob.ExpandToSecret().ToEd25519Bytes(), MiniSecretBob.GetPair().Public.Key); - - private readonly string _nodeUrl = "ws://127.0.0.1:9944"; - - private SubstrateNetwork _client; - - [SetUp] - public void Setup() - { - // create client - _client = new SubstrateNetwork(Alice, Substrate.Integration.Helper.NetworkType.Live, _nodeUrl); - } - - [TearDown] - public void TearDown() - { - // dispose client - _client.SubstrateClient.Dispose(); - } - [Test] public async Task CreateGameTestAsync() { @@ -65,5 +42,12 @@ public async Task CreateGameTestAsync() Assert.That(await _client.DisconnectAsync(), Is.True); Assert.That(_client.IsConnected, Is.False); } + + [Test] + public async Task GetEloRatingTestAsync() + { + var elo = await _client.GetRatingStorageAsync(Alice.ToString(), CancellationToken.None); + Assert.That(elo, Is.GreaterThan(0)); + } } } diff --git a/Substrate.Hexalem.Integration.Test/IntegrationCommonTests.cs b/Substrate.Hexalem.Integration.Test/IntegrationCommonTests.cs new file mode 100644 index 0000000..7ce2e72 --- /dev/null +++ b/Substrate.Hexalem.Integration.Test/IntegrationCommonTests.cs @@ -0,0 +1,66 @@ +using Substrate.Integration; +using Substrate.NET.Wallet.Keyring; +using Substrate.NetApi.Model.Types; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Substrate.Hexalem.Integration.Test +{ + public abstract class IntegrationCommonTests + { + protected readonly string _nodeUrl = "ws://127.0.0.1:9944"; + protected SubstrateNetwork _client; + + protected Keyring _keyring = new Keyring(); + + private Account? _alice; + public Account Alice + { + get + { + if (_alice is null) + { + _alice = _keyring.AddFromUri("//Alice", new Meta() { Name = "Alice" }, KeyType.Sr25519).Account; + } + + return _alice; + } + } + + private Account? _bob; + public Account Bob + { + get + { + if (_bob is null) + _bob = _keyring.AddFromUri("//Bob", new Meta() { Name = "Bob" }, KeyType.Sr25519).Account; + + return _bob; + } + } + + [SetUp] + public async Task Setup() + { + // create client + _client = new SubstrateNetwork(Alice, Substrate.Integration.Helper.NetworkType.Live, _nodeUrl); + + Assert.That(_client, Is.Not.Null); + Assert.That(_client.IsConnected, Is.False); + + Assert.That(await _client.ConnectAsync(true, true, CancellationToken.None), Is.True); + Assert.That(_client.IsConnected, Is.True); + } + + [TearDown] + public void TearDown() + { + // dispose client + _client.SubstrateClient.Dispose(); + } + + } +} diff --git a/Substrate.Hexalem.Integration.Test/MatchmakingTests.cs b/Substrate.Hexalem.Integration.Test/MatchmakingTests.cs new file mode 100644 index 0000000..e6f30f1 --- /dev/null +++ b/Substrate.Hexalem.Integration.Test/MatchmakingTests.cs @@ -0,0 +1,221 @@ +using Substrate.Hexalem.NET.NetApiExt.Generated.Model.hexalem_runtime; +using Substrate.Hexalem.NET.NetApiExt.Generated.Model.pallet_hexalem.types; +using Substrate.Hexalem.NET.NetApiExt.Generated.Model.pallet_hexalem.types.game; +using Substrate.Hexalem.NET.NetApiExt.Generated.Model.sp_runtime.multiaddress; +using Substrate.Integration; +using Substrate.Integration.Call; +using Substrate.Integration.Helper; +using Substrate.NET.Wallet.Extensions; +using Substrate.NET.Wallet.Keyring; +using Substrate.NetApi.Model.Types; +using Substrate.NetApi.Model.Types.Base; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Security.Principal; +using System.Text; +using System.Threading.Tasks; + +namespace Substrate.Hexalem.Integration.Test +{ + public class MatchmakingTests : IntegrationCommonTests + { + private readonly string _nodeUrl = "ws://127.0.0.1:9944"; + + private SubstrateNetwork _client; + + [SetUp] + public async Task Setup() + { + // create client + _client = new SubstrateNetwork(Alice, Substrate.Integration.Helper.NetworkType.Live, _nodeUrl); + + Assert.That(_client, Is.Not.Null); + Assert.That(_client.IsConnected, Is.False); + + Assert.That(await _client.ConnectAsync(true, true, CancellationToken.None), Is.True); + Assert.That(_client.IsConnected, Is.True); + } + + private async Task ShouldBeInQueueAsync(Account account) + { + var isInQueue = await _client.IsPlayerInMatchmakingAsync(account, CancellationToken.None); + var bobMatchmakingState = await _client.GetMatchmakingStateAsync(account, null, CancellationToken.None); + + Assert.That(isInQueue, Is.True); + + Assert.That(bobMatchmakingState, Is.Not.Null); + Assert.That(bobMatchmakingState.MatchmakingState, Is.EqualTo(MatchmakingState.Matchmaking)); + } + + private async Task ShouldNotBeInQueueAsync(Account account) + { + var isInQueue = await _client.IsPlayerInMatchmakingAsync(account, CancellationToken.None); + var bobMatchmakingState = await _client.GetMatchmakingStateAsync(account, null, CancellationToken.None); + + Assert.That(isInQueue, Is.False); + + Assert.That(bobMatchmakingState, Is.Not.Null); + Assert.That(bobMatchmakingState.MatchmakingState, Is.EqualTo(MatchmakingState.None)); + } + + private async Task PlayersAcceptMathAsync(int concurrentTasksAllowed, List players) + { + foreach (var player in players) + { + await _client.AcceptAsync(player, concurrentTasksAllowed, CancellationToken.None); + Thread.Sleep(500); + } + } + + private async Task> EnsureGamesAreCreatedButNobodyJoinedAsync(List players) + { + List gamesId = new List(); + // Matchmaking is not done, game has been create but players did not accept yet + foreach (var player in players) + { + var playerMatchmakingState = await _client.GetMatchmakingStateAsync(player, null, CancellationToken.None); + Assert.That(playerMatchmakingState.MatchmakingState, Is.EqualTo(MatchmakingState.Joined)); + + Assert.That(playerMatchmakingState.GameId, Is.Not.Null); + if (!gamesId.Contains(playerMatchmakingState.GameId)) + gamesId.Add(playerMatchmakingState.GameId); + + var game = await _client.GetGameAsync(playerMatchmakingState.GameId, null, CancellationToken.None); + + Assert.That(game, Is.Not.Null); + Assert.That(game.PlayerAccepted, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(game.PlayerAccepted.All(x => x), Is.False); + Assert.That(game.State, Is.EqualTo(GameState.Accepting)); + }); + } + + return gamesId; + } + + [Test] + public async Task PlayerJoinQueueTestAsync() + { + await ShouldNotBeInQueueAsync(Bob); + + _ = await _client.QueueAsync(Bob, 1, CancellationToken.None); + + Thread.Sleep(6_000); + + await ShouldBeInQueueAsync(Bob); + } + + [Test] + public async Task Matchmaking_WhenEnoughPlayerHaveJoin_ShouldMatchAndCreateGameAsync() + { + int concurrentTasksAllowed = 20; + var players = new List(); + var balanceTransfer = new List(); + + for (int i = 0; i < 10; i++) + { + var player = _keyring.AddFromUri($"MatchmakingTestEnoughPlayer_{i}", new Meta() { Name = $"TestPlayer{i}" }, KeyType.Sr25519); + Assert.That(player, Is.Not.Null); + + balanceTransfer.Add(PalletBalances.BalancesTransferKeepAlive( + player.Account.ToAccountId32(), + new BigInteger(1000 * SubstrateNetwork.DECIMALS))); + + players.Add(player.Account); + } + + _ = await _client.BatchAllAsync(balanceTransfer, concurrentTasksAllowed, CancellationToken.None); + + Thread.Sleep(15_000); + + // Now each player queue + foreach (var player in players.Take(9)) + { + await ShouldNotBeInQueueAsync(player); + + _ = await _client.QueueAsync(player, concurrentTasksAllowed, CancellationToken.None); + Thread.Sleep(1_500); // To simulate asynchronous queing + } + + Thread.Sleep(15_000); + + foreach (var player in players.Take(9)) + { + var playerMatchmakingState = await _client.GetMatchmakingStateAsync(player, null, CancellationToken.None); + + Assert.That(playerMatchmakingState, Is.Not.Null); + Assert.That(playerMatchmakingState.MatchmakingState, Is.EqualTo(MatchmakingState.Matchmaking)); + Assert.That(playerMatchmakingState.GameId, Is.Null); + } + + // Now the last one queue + _ = await _client.QueueAsync(players.Last(), concurrentTasksAllowed, CancellationToken.None); + + Thread.Sleep(15_000); + + List gamesId = await EnsureGamesAreCreatedButNobodyJoinedAsync(players); + await PlayersAcceptMathAsync(concurrentTasksAllowed, players); + + Thread.Sleep(15_000); + + foreach (var gameId in gamesId) + { + var game = await _client.GetGameAsync(gameId, null, CancellationToken.None); + + Assert.That(game, Is.Not.Null); + Assert.That(game.State, Is.EqualTo(GameState.Playing)); + } + } + + [Test] + public async Task Matchmaking_WhenNotEnoughPlayerHaveJoin_ButExceedTenBlocks_ShouldMatchAndCreateGameAsync() + { + int concurrentTasksAllowed = 20; + var players = new List(); + for (int i = 0; i < 4; i++) + { + var player = _keyring.AddFromUri($"MatchmakingTestNotEnoughPlayer_{i}", new Meta() { Name = $"TestPlayer{i}" }, KeyType.Sr25519); + Assert.That(player, Is.Not.Null); + + // Alice send them some token + _ = await _client.TransferKeepAliveAsync( + Alice, + player.Account.ToAccountId32(), + new BigInteger(1000 * SubstrateNetwork.DECIMALS), + concurrentTasksAllowed, CancellationToken.None); + + players.Add(player.Account); + } + + Thread.Sleep(15_000); + + // Now each player queue + foreach (var player in players) + { + await ShouldNotBeInQueueAsync(player); + + _ = await _client.QueueAsync(player, concurrentTasksAllowed, CancellationToken.None); + Thread.Sleep(1_500); // To simulate asynchronous queing + } + + // Now wait > 10 blocks to trigger the matchmaking even if there is not enough player + Thread.Sleep(11 * 6_000); + + List gamesId = await EnsureGamesAreCreatedButNobodyJoinedAsync(players); + await PlayersAcceptMathAsync(concurrentTasksAllowed, players); + + Thread.Sleep(15_000); + + foreach (var gameId in gamesId) + { + var game = await _client.GetGameAsync(gameId, null, CancellationToken.None); + + Assert.That(game, Is.Not.Null); + Assert.That(game.State, Is.EqualTo(GameState.Playing)); + } + } + } +} diff --git a/Substrate.Hexalem.Integration.Test/Substrate.Hexalem.Integration.Test.csproj b/Substrate.Hexalem.Integration.Test/Substrate.Hexalem.Integration.Test.csproj index 31454a3..179bd0b 100644 --- a/Substrate.Hexalem.Integration.Test/Substrate.Hexalem.Integration.Test.csproj +++ b/Substrate.Hexalem.Integration.Test/Substrate.Hexalem.Integration.Test.csproj @@ -15,6 +15,7 @@ + diff --git a/Substrate.Hexalem.Integration/Model/BoardSharp.cs b/Substrate.Hexalem.Integration/Model/BoardSharp.cs index d37ff24..5542f64 100644 --- a/Substrate.Hexalem.Integration/Model/BoardSharp.cs +++ b/Substrate.Hexalem.Integration/Model/BoardSharp.cs @@ -18,8 +18,15 @@ public BoardSharp(HexBoard result) Resources = result.Resources.Value.Select(x => (byte)x).ToArray(); HexGrid = result.HexGrid.Value.Value.Select(x => new TileSharp(x)).ToArray(); + + GameId = result.GameId.Value.Select(p => p.Value).ToArray(); } + /// + /// The game identifier + /// + public byte[] GameId { get; private set; } + /// /// Resources /// diff --git a/Substrate.Hexalem.Integration/Model/MatchmakingStateSharp.cs b/Substrate.Hexalem.Integration/Model/MatchmakingStateSharp.cs index 616291a..f2aaf02 100644 --- a/Substrate.Hexalem.Integration/Model/MatchmakingStateSharp.cs +++ b/Substrate.Hexalem.Integration/Model/MatchmakingStateSharp.cs @@ -1,5 +1,6 @@ using Substrate.Hexalem.NET.NetApiExt.Generated.Model.pallet_hexalem.types; using Substrate.Hexalem.NET.NetApiExt.Generated.Types.Base; +using Substrate.NetApi.Model.Types; using System.Linq; namespace Substrate.Hexalem.Integration.Model @@ -32,5 +33,10 @@ public MatchmakingStateSharp(EnumMatchmakingState matchmakingState) /// Game Id /// public byte[]? GameId { get; private set; } + + /// + /// Return true if the game is created + /// + public bool IsGameFound => GameId != null; } } \ No newline at end of file diff --git a/Substrate.Hexalem.Integration/PalletHexalem.cs b/Substrate.Hexalem.Integration/PalletHexalem.cs index 7a4315b..c6a769e 100644 --- a/Substrate.Hexalem.Integration/PalletHexalem.cs +++ b/Substrate.Hexalem.Integration/PalletHexalem.cs @@ -1,4 +1,5 @@ using Serilog; +using Substrate.Hexalem.Integration.Helper; using Substrate.Hexalem.Integration.Model; using Substrate.Hexalem.NET.NetApiExt.Generated.Model.pallet_hexalem.types.board.resource; using Substrate.Hexalem.NET.NetApiExt.Generated.Model.sp_core.crypto; @@ -22,8 +23,28 @@ namespace Substrate.Integration /// public partial class SubstrateNetwork : BaseClient { + private HexalemModuleConstants _hexalemConstants; + + /// + /// Get Hexalem constant from NetApiExt + /// + public HexalemModuleConstants HexalemConstants + { + get + { + if(_hexalemConstants == null) + { + _hexalemConstants = new HexalemModuleConstants(); + } + + return _hexalemConstants; + } + } #region storage + public Task GetMatchmakingStateAsync(Account player, string? blockHash, CancellationToken token) + => GetMatchmakingStateAsync(player.ToString(), blockHash, token); + /// /// Get game /// @@ -31,6 +52,7 @@ public partial class SubstrateNetwork : BaseClient /// /// /// + public async Task GetMatchmakingStateAsync(string playerAddress, string? blockHash, CancellationToken token) { if (!IsConnected) @@ -40,11 +62,16 @@ public partial class SubstrateNetwork : BaseClient } var key = new AccountId32(); - key.Create(Utils.GetPublicKeyFrom(playerAddress)); + key.Create(Utils.GetPublicKeyFrom(playerAddress)); var result = await SubstrateClient.HexalemModuleStorage.MatchmakingStateStorage(key, blockHash, token); - if (result == null) return null; + if (result == null) + { + // Get default value + result = new Hexalem.NET.NetApiExt.Generated.Model.pallet_hexalem.types.EnumMatchmakingState(); + result.Create(HexalemModuleStorage.MatchmakingStateStorageDefault()); + } return new MatchmakingStateSharp(result); } @@ -64,7 +91,7 @@ public partial class SubstrateNetwork : BaseClient return null; } - var key = new Hexalem.NET.NetApiExt.Generated.Types.Base.Arr32U8(); + var key = new Arr32U8(); key.Create(gameId); var result = await SubstrateClient.HexalemModuleStorage.GameStorage(key, blockHash, token); @@ -99,6 +126,114 @@ public partial class SubstrateNetwork : BaseClient return new BoardSharp(result); } + /// + /// Get the elo rating of a player + /// + /// + /// + /// + public async Task GetRatingStorageAsync(string playerAddress, CancellationToken token) + { + var account = new AccountId32(); + account.Create(Utils.GetPublicKeyFrom(playerAddress)); + + Log.Debug($"Fetching elo rating for address {playerAddress} | account bytes = {account.Bytes}"); + var result = await SubstrateClient.EloModuleStorage.RatingStorage(account, null, token); + + if (result == null) + { + result = new U16(); + result.Create(EloModuleStorage.RatingStorageDefault()); + } + + Log.Debug($"Elo rating fetched for address {playerAddress} | value = {result}"); + return result; + } + + /// + /// Return player bracket (need to be changed) + /// + /// + /// + /// + public async Task GetPlayerBracketAsync(Account player, CancellationToken token) + { + return 0; + } + + /// + /// + /// + /// + /// + /// + /// + public async Task GetPlayerBracketIndexAsync(byte bracketIndex, uint bracketKey, CancellationToken token) + { + var tuple = new BaseTuple(new U8(bracketIndex), new U16((ushort)bracketKey)); + + var result = await SubstrateClient.MatchmakerModuleStorage.BracketIndexKeyMap(tuple, null, token); + + if (result == null) + { + Log.Warning("No player for bracket index = {bracketIndex} and bracket key {bracketKey}", bracketIndex, bracketKey); + return null; + } + + return Utils.GetAddressFrom(result.Value.Bytes); + } + + /// + /// + /// + /// + /// + /// + public async Task<(uint, uint)> GetBracketIndicesAsync(byte bracketIndex, CancellationToken token) + { + var result = await SubstrateClient.MatchmakerModuleStorage.BracketIndices(new U8(bracketIndex), null, token); + + if (result == null) + { + result = new BaseTuple(); + result.Create(MatchmakerModuleStorage.BracketIndicesDefault()); + } + + return ((uint)result.Value[0].As().Value, (uint)result.Value[1].As().Value); + } + + /// + /// + /// + /// + /// + /// + public async Task IsPlayerInMatchmakingAsync(Account player, CancellationToken token) + { + var result = await SubstrateClient.MatchmakerModuleStorage.KeyPresentMap(player.ToAccountId32(), null, token); + + if (result == null) + { + result = new Bool(); + result.Create(MatchmakerModuleStorage.KeyPresentMapDefault()); + } + + return result; + } + + /// + /// Return the number of brackets + /// + /// + /// + public async Task GetBracketsCountAsync(CancellationToken token) + { + var result = await SubstrateClient.MatchmakerModuleStorage.BracketsCount(null, token); + + if (result == null) return uint.MinValue; + + return (uint)result.Value; + } #endregion storage #region call @@ -228,6 +363,22 @@ public partial class SubstrateNetwork : BaseClient return await GenericExtrinsicAsync(account, extrinsicType, extrinsic, concurrentTasks, token); } + /// + /// Force the start of the match + /// + /// + /// + /// + /// + public async Task ForceAcceptMatch(Account account, int concurrentTasks, CancellationToken token) + { + var extrinsicType = $"Hexalem.ForceAcceptMatch"; + + var extrinsic = HexalemModuleCalls.ForceAcceptMatch(); + + return await GenericExtrinsicAsync(account, extrinsicType, extrinsic, concurrentTasks, token); + } + /// /// Root delete game, needs to be called by Sudo. /// @@ -251,5 +402,17 @@ public partial class SubstrateNetwork : BaseClient } #endregion call + + #region Constants + + /// + /// Return nb max block to accept the match + /// + /// + public uint BlocksToAcceptMatchLimit() + { + return (uint)HexalemConstants.BlocksToAcceptMatchLimit().Value; + } + #endregion } } \ No newline at end of file