diff --git a/Controllers/GamesController.cs b/Controllers/GamesController.cs new file mode 100644 index 0000000..5b266a8 --- /dev/null +++ b/Controllers/GamesController.cs @@ -0,0 +1,39 @@ +using Mastermind.Api.Models; +using Mastermind.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Mastermind.Api.Controllers +{ + [Authorize] + [ApiController] + public class GamesController : ControllerBase + { + public GamesController(GameService gameService) => GameService = gameService; + + public GameService GameService { get; } + + [HttpGet("api/games")] + public IEnumerable GetGames() => + GameService.GetGames(); + + [HttpGet("api/games/{gameId}")] + public Game GetGame(Guid gameId) => + GameService.GetGame(gameId); + + [HttpPost("api/games")] + public Game AddNewGame([FromBody]GameOptions options) => + GameService.AddNewGame(options); + + [HttpPost("api/games/{gameId}/guess")] + public async Task> PostGuessAsync([FromRoute]Guid gameId, [FromBody] IReadOnlyList numbers) => + await GameService.GuessAsync(gameId, numbers); + + [HttpGet("api/highscores")] + public async Task> GetHighScores(int entries = 20) => + await GameService.GetHighScores(entries); + } +} \ No newline at end of file diff --git a/Controllers/UserController.cs b/Controllers/UserController.cs new file mode 100644 index 0000000..972877c --- /dev/null +++ b/Controllers/UserController.cs @@ -0,0 +1,38 @@ +using Mastermind.Api.Models; +using Mastermind.Api.Services; +using Microsoft.AspNetCore.Mvc; +using System.Net; +using System.Threading.Tasks; + +namespace Mastermind.Api.Controllers +{ + [ApiController] + public class UserController : ControllerBase + { + public UserService UserService { get; } + + public UserController(UserService userService) => UserService = userService; + + [HttpPost("api/user/register")] + [ProducesResponseType((int)HttpStatusCode.NoContent)] + public async Task RegisterAsync([FromBody] UserAuthModel userAuthModel) + { + await UserService.HttpCookieSignInAsync(await UserService.RegisterAsync(userAuthModel.Username, userAuthModel.Password)); + return NoContent(); + } + + [HttpPost("api/user/login")] + [ProducesResponseType((int)HttpStatusCode.NoContent)] + [ProducesResponseType((int)HttpStatusCode.BadRequest)] + public async Task LoginAsync([FromBody] UserAuthModel userAuthModel) + { + var id = await UserService.CheckPasswordAsync(userAuthModel.Username, userAuthModel.Password); + if (id != null) + { + await UserService.HttpCookieSignInAsync(id.Value); + return NoContent(); + } + return BadRequest(); + } + } +} \ No newline at end of file diff --git a/Data/Entities/Score.cs b/Data/Entities/Score.cs new file mode 100644 index 0000000..16d8220 --- /dev/null +++ b/Data/Entities/Score.cs @@ -0,0 +1,20 @@ +using System; + +namespace Mastermind.Api.Data.Entities +{ + public class Score + { + public int Id { get; set; } + public Guid GameId { get; set; } + public DateTimeOffset GameStarted { get; set; } + public int KeyLength { get; set; } + public double DurationInSeconds { get; set; } + public Guid PlayerId { get; set; } + public User Player { get; set; } = null!; + public int GuessesMade { get; set; } + public int PossibleValues { get; set; } + public int MaximumPossibleGuesses { get; set; } + public bool AllowDuplicates { get; set; } + public bool Won { get; set; } + } +} diff --git a/Data/Entities/User.cs b/Data/Entities/User.cs new file mode 100644 index 0000000..17c81ab --- /dev/null +++ b/Data/Entities/User.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; + +namespace Mastermind.Api.Data.Entities +{ + public class User + { + public Guid Id { get; set; } + public string Username { get; set; } = null!; + public string PasswordHash { get; set; } = null!; + public ICollection UserScores { get; set; } = null!; + } +} diff --git a/Data/MastermindDbContext.cs b/Data/MastermindDbContext.cs new file mode 100644 index 0000000..cdef621 --- /dev/null +++ b/Data/MastermindDbContext.cs @@ -0,0 +1,30 @@ +using Mastermind.Api.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Mastermind.Api.Data +{ + public class MastermindDbContext : DbContext + { + public MastermindDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Users { get; set; } = null!; + public DbSet Scores { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity(e => + { + e.HasIndex(s => s.Won); + e.HasIndex(s => new { s.KeyLength, s.PossibleValues, s.MaximumPossibleGuesses, s.GuessesMade, s.DurationInSeconds }); + }); + + builder.Entity(e => + { + e.HasIndex(u => u.Username).IsUnique(); + }); + } + } +} diff --git a/Data/Migrations/20200406101101_InitialMastermindSchema.Designer.cs b/Data/Migrations/20200406101101_InitialMastermindSchema.Designer.cs new file mode 100644 index 0000000..f05a7a2 --- /dev/null +++ b/Data/Migrations/20200406101101_InitialMastermindSchema.Designer.cs @@ -0,0 +1,105 @@ +// +using System; +using Mastermind.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Mastermind.Api.Data.Migrations +{ + [DbContext(typeof(MastermindDbContext))] + [Migration("20200406101101_InitialMastermindSchema")] + partial class InitialMastermindSchema + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.3") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("Mastermind.Api.Data.Entities.Score", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AllowDuplicates") + .HasColumnType("bit"); + + b.Property("DurationInSeconds") + .HasColumnType("float"); + + b.Property("GameId") + .HasColumnType("uniqueidentifier"); + + b.Property("GameStarted") + .HasColumnType("datetimeoffset"); + + b.Property("GuessesMade") + .HasColumnType("int"); + + b.Property("KeyLength") + .HasColumnType("int"); + + b.Property("MaximumPossibleGuesses") + .HasColumnType("int"); + + b.Property("PlayerId") + .HasColumnType("uniqueidentifier"); + + b.Property("PossibleValues") + .HasColumnType("int"); + + b.Property("Won") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("Won"); + + b.HasIndex("KeyLength", "PossibleValues", "MaximumPossibleGuesses", "GuessesMade", "DurationInSeconds"); + + b.ToTable("Scores"); + }); + + modelBuilder.Entity("Mastermind.Api.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Username") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Mastermind.Api.Data.Entities.Score", b => + { + b.HasOne("Mastermind.Api.Data.Entities.User", "Player") + .WithMany("UserScores") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20200406101101_InitialMastermindSchema.cs b/Data/Migrations/20200406101101_InitialMastermindSchema.cs new file mode 100644 index 0000000..7b5ddf8 --- /dev/null +++ b/Data/Migrations/20200406101101_InitialMastermindSchema.cs @@ -0,0 +1,82 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Mastermind.Api.Data.Migrations +{ + public partial class InitialMastermindSchema : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(nullable: false), + Username = table.Column(nullable: false), + PasswordHash = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Scores", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + GameId = table.Column(nullable: false), + GameStarted = table.Column(nullable: false), + KeyLength = table.Column(nullable: false), + DurationInSeconds = table.Column(nullable: false), + PlayerId = table.Column(nullable: false), + GuessesMade = table.Column(nullable: false), + PossibleValues = table.Column(nullable: false), + MaximumPossibleGuesses = table.Column(nullable: false), + AllowDuplicates = table.Column(nullable: false), + Won = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Scores", x => x.Id); + table.ForeignKey( + name: "FK_Scores_Users_PlayerId", + column: x => x.PlayerId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Scores_PlayerId", + table: "Scores", + column: "PlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_Scores_Won", + table: "Scores", + column: "Won"); + + migrationBuilder.CreateIndex( + name: "IX_Scores_KeyLength_PossibleValues_MaximumPossibleGuesses_GuessesMade_DurationInSeconds", + table: "Scores", + columns: new[] { "KeyLength", "PossibleValues", "MaximumPossibleGuesses", "GuessesMade", "DurationInSeconds" }); + + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + table: "Users", + column: "Username", + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Scores"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/Data/Migrations/MastermindDbContextModelSnapshot.cs b/Data/Migrations/MastermindDbContextModelSnapshot.cs new file mode 100644 index 0000000..4110c92 --- /dev/null +++ b/Data/Migrations/MastermindDbContextModelSnapshot.cs @@ -0,0 +1,103 @@ +// +using System; +using Mastermind.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Mastermind.Api.Data.Migrations +{ + [DbContext(typeof(MastermindDbContext))] + partial class MastermindDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.3") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("Mastermind.Api.Data.Entities.Score", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AllowDuplicates") + .HasColumnType("bit"); + + b.Property("DurationInSeconds") + .HasColumnType("float"); + + b.Property("GameId") + .HasColumnType("uniqueidentifier"); + + b.Property("GameStarted") + .HasColumnType("datetimeoffset"); + + b.Property("GuessesMade") + .HasColumnType("int"); + + b.Property("KeyLength") + .HasColumnType("int"); + + b.Property("MaximumPossibleGuesses") + .HasColumnType("int"); + + b.Property("PlayerId") + .HasColumnType("uniqueidentifier"); + + b.Property("PossibleValues") + .HasColumnType("int"); + + b.Property("Won") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("Won"); + + b.HasIndex("KeyLength", "PossibleValues", "MaximumPossibleGuesses", "GuessesMade", "DurationInSeconds"); + + b.ToTable("Scores"); + }); + + modelBuilder.Entity("Mastermind.Api.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Username") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Mastermind.Api.Data.Entities.Score", b => + { + b.HasOne("Mastermind.Api.Data.Entities.User", "Player") + .WithMany("UserScores") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Exceptions/UserException.cs b/Exceptions/UserException.cs new file mode 100644 index 0000000..fbdff4a --- /dev/null +++ b/Exceptions/UserException.cs @@ -0,0 +1,13 @@ +using System; + +namespace Mastermind.Api +{ + [Serializable] + public class UserException : Exception + { + public UserException() { } + public UserException(string message) : base(message) { } + public UserException(string message, Exception inner) : base(message, inner) { } + protected UserException(System.Runtime.Serialization.SerializationInfo info,System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } +} diff --git a/Executable/Mastermind.Api.v1.exe b/Executable/Mastermind.Api.v1.exe new file mode 100644 index 0000000..c4372f9 Binary files /dev/null and b/Executable/Mastermind.Api.v1.exe differ diff --git a/Mastermind.Api.csproj b/Mastermind.Api.csproj new file mode 100644 index 0000000..ce11a67 --- /dev/null +++ b/Mastermind.Api.csproj @@ -0,0 +1,17 @@ + + + + netcoreapp3.1 + enable + aspnet-Mastermind.Api-EB54349E-1CDB-4BBF-A521-6129FF8B0795 + + + + + + + + + + + diff --git a/Mastermind.sln b/Mastermind.sln new file mode 100644 index 0000000..be30307 --- /dev/null +++ b/Mastermind.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29926.136 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mastermind.Api", "Mastermind.Api.csproj", "{D0A3F747-0660-4921-BD2B-9893977868D4}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D0A3F747-0660-4921-BD2B-9893977868D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0A3F747-0660-4921-BD2B-9893977868D4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0A3F747-0660-4921-BD2B-9893977868D4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0A3F747-0660-4921-BD2B-9893977868D4}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B6C06634-069F-40F1-8E61-2CAEACAF8671} + EndGlobalSection +EndGlobal diff --git a/Models/Game.cs b/Models/Game.cs new file mode 100644 index 0000000..32aafb0 --- /dev/null +++ b/Models/Game.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; + +namespace Mastermind.Api.Models +{ + public class Game + { + public Game(Guid userId, GameOptions options) + { + UserId = userId; + Options = options; + GameCreated = DateTimeOffset.UtcNow; + + var random = new Random(); + if (!options.AllowDuplicates && options.KeyLength > options.MaximumKeyValue) + throw new UserException($"The length of the secret key ({options.KeyLength}) must be at least the maximum key value ({options.MaximumKeyValue}) if duplicates aren't allowed."); + SecretKeys = options.AllowDuplicates + ? Enumerable.Range(0, options.KeyLength).Select(_ => random.Next(1, options.MaximumKeyValue + 1)).ToArray() + : Enumerable.Range(1, options.MaximumKeyValue).OrderBy(_ => random.Next()).Take(options.KeyLength).ToArray(); + } + + [JsonIgnore] + public TimeSpan ElapsedTime => DateTimeOffset.UtcNow - GameCreated; + public double ElapsedSeconds => ElapsedTime.TotalSeconds; + public DateTimeOffset GameCreated { get; } + public List GivenGuesses { get; } = new List(); + public Guid Id { get; } = Guid.NewGuid(); + + public Guid UserId { get; } + public GameOptions Options { get; } + [JsonIgnore] + public IReadOnlyList SecretKeys { get; } + public PlayState PlayState => GivenGuesses.Count >= Options.MaximumNumberOfPossibleGuesses + ? PlayState.Lost + : GivenGuesses.Count > 0 && GivenGuesses[^1].Numbers.SequenceEqual(SecretKeys) + ? PlayState.Won + : PlayState.InProgress; + } +} diff --git a/Models/GameOptions.cs b/Models/GameOptions.cs new file mode 100644 index 0000000..ee5098c --- /dev/null +++ b/Models/GameOptions.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Mastermind.Api.Models +{ + public class GameOptions + { + [Range(2, 10)] + public int MaximumKeyValue { get; set; } + [Range(4, 20)] + public int MaximumNumberOfPossibleGuesses { get; set; } + [Range(2, 8)] + public int KeyLength { get; set; } + public bool AllowDuplicates { get; set; } + } +} diff --git a/Models/Guess.cs b/Models/Guess.cs new file mode 100644 index 0000000..74379e9 --- /dev/null +++ b/Models/Guess.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace Mastermind.Api.Models +{ + public class Guess + { + public Guess(IReadOnlyList numbers, int numbersAtRightPlace, int numbersAtWrongPlace) + { + Numbers = numbers; + NumbersAtRightPlace = numbersAtRightPlace; + NumbersAtWrongPlace = numbersAtWrongPlace; + } + + public IReadOnlyList Numbers { get; } + public int NumbersAtRightPlace { get; } + public int NumbersAtWrongPlace { get; } + } +} diff --git a/Models/HighScore.cs b/Models/HighScore.cs new file mode 100644 index 0000000..a65364a --- /dev/null +++ b/Models/HighScore.cs @@ -0,0 +1,26 @@ +using System; + +namespace Mastermind.Api.Models +{ + public class HighScore + { + public HighScore(string user, double playTimeInSeconds, DateTime date, int guessesMade, int possibleValues, int maximumPossibleGuesses, bool allowDuplicates) + { + User = user; + PlayTimeInSeconds = playTimeInSeconds; + Date = date; + GuessesMade = guessesMade; + PossibleValues = possibleValues; + MaximumPossibleGuesses = maximumPossibleGuesses; + AllowDuplicates = allowDuplicates; + } + + public string User { get; } + public double PlayTimeInSeconds { get; } + public DateTime Date { get; } + public int GuessesMade { get; } + public int PossibleValues { get; } + public int MaximumPossibleGuesses { get; } + public bool AllowDuplicates { get; } + } +} diff --git a/Models/PlayState.cs b/Models/PlayState.cs new file mode 100644 index 0000000..d7197b9 --- /dev/null +++ b/Models/PlayState.cs @@ -0,0 +1,9 @@ +namespace Mastermind.Api.Models +{ + public enum PlayState + { + InProgress, + Lost, + Won + } +} diff --git a/Models/UserAuthModel.cs b/Models/UserAuthModel.cs new file mode 100644 index 0000000..97c368c --- /dev/null +++ b/Models/UserAuthModel.cs @@ -0,0 +1,8 @@ +namespace Mastermind.Api.Models +{ + public class UserAuthModel + { + public string Username { get; set; } = null!; + public string Password { get; set; } = null!; + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..e79b7a2 --- /dev/null +++ b/Program.cs @@ -0,0 +1,39 @@ +using Mastermind.Api.Data; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using System.Linq; +using System.Threading.Tasks; + +namespace Mastermind.Api +{ + public static class Program + { + public static async Task Main(string[] args) => + (await CreateHostBuilder(args) + .Build() + .MigrateOrReacreateDatabaseAsync()) + .Run(); + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()); + + private static async Task MigrateOrReacreateDatabaseAsync(this IHost host) + { + using var scope = host.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var allMigrations = dbContext.Database.GetMigrations().ToHashSet(); + var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync(); + if (appliedMigrations.Any(m => !allMigrations.Contains(m))) + { + await dbContext.Database.EnsureDeletedAsync(); + await dbContext.Database.MigrateAsync(); + } + else if (allMigrations.Any(m => !appliedMigrations.Contains(m))) + await dbContext.Database.MigrateAsync(); + return host; + } + } +} diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..92ab429 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "Mastermind.Api": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Services/GameRepository.cs b/Services/GameRepository.cs new file mode 100644 index 0000000..5d3ffb3 --- /dev/null +++ b/Services/GameRepository.cs @@ -0,0 +1,19 @@ +using Mastermind.Api.Data; +using Mastermind.Api.Models; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace Mastermind.Api.Services +{ + public class GameRepository + { + private ConcurrentDictionary Games { get; } = new ConcurrentDictionary(); + public bool TryAddGame(Game game) => Games.TryAdd(game.Id, game); + public bool TryRemoveGame(Game game) => Games.TryRemove(game.Id, out _); + public bool TryGetGame(Guid gameId, [NotNullWhen(true)]out Game game) => Games.TryGetValue(gameId, out game!); + public IEnumerable GetGamesForUser(Guid userId) => Games.Values.Where(g => g.UserId == userId).ToList(); + } +} \ No newline at end of file diff --git a/Services/GameService.cs b/Services/GameService.cs new file mode 100644 index 0000000..15a8643 --- /dev/null +++ b/Services/GameService.cs @@ -0,0 +1,74 @@ +using Mastermind.Api.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Mastermind.Api.Services +{ + public class GameService + { + private GameRepository GameRepository { get; } + public ScoreRepository ScoreRepository { get; } + public UserService UserService { get; } + + public GameService(GameRepository gameRepository, ScoreRepository scoreRepository, UserService userService) + { + GameRepository = gameRepository; + ScoreRepository = scoreRepository; + UserService = userService; + } + public Game AddNewGame(GameOptions options) + { + var game = new Game(UserService.GetCurrentUserId(), options); + if (!GameRepository.TryAddGame(game)) + throw new UserException($"Game with key {game.Id} could not be stored in repository."); + return game; + } + + public IEnumerable GetGames() => + GameRepository.GetGamesForUser(UserService.GetCurrentUserId()); + + public async Task> GetHighScores(int entries) => + await ScoreRepository.GetHighScoresAsync(entries); + + public Game GetGame(Guid gameId) => + GameRepository.TryGetGame(gameId, out var game) && game.UserId == UserService.GetCurrentUserId() + ? game + : throw new UserException($"The game with key {gameId} was not found or was assigned to another user."); + + public async Task GuessAsync(Guid gameId, IReadOnlyList numbers) + { + var game = GetGame(gameId); + + if (game.PlayState != PlayState.InProgress) + throw new UserException($"The game with key {gameId} is not in progress. The game state is {game.PlayState}."); + + if (numbers?.Count != game.SecretKeys.Count) + throw new UserException($"The provided number of guessed items ({numbers?.Count}) does not equal the game's secret key length ({game.SecretKeys.Count})."); + + var lessThanOneNumbers = string.Join(", ", numbers.Where(n => n < 1).ToList()); + if (lessThanOneNumbers != "") + throw new UserException($"The following numbers provided in the guess are lower than the minimum supported (1): {lessThanOneNumbers}."); + + var wrongNumbers = string.Join(", ", numbers.Where(n => n > game.Options.MaximumKeyValue).ToList()); + if (wrongNumbers != "") + throw new UserException($"The following numbers provided in the guess are higher than the possible maximum for the game ({game.Options.MaximumKeyValue}): {wrongNumbers}."); + + var guessNumberMap = numbers.Select((k, i) => (k, i)).Where(n => n.k != game.SecretKeys[n.i]).GroupBy(n => n.k).ToDictionary(g => g.Key, g => g.Count()); + + game.GivenGuesses.Add(new Guess(numbers, + numbersAtRightPlace: game.SecretKeys.Select((k, i) => (k, i)).Count(n => n.k == numbers[n.i]), + numbersAtWrongPlace: game.SecretKeys.Select((k, i) => (k, i)).Where(n => n.k != numbers[n.i]).GroupBy(n => n.k).ToDictionary(g => g.Key, g => g.Count()) + .Sum(n => Math.Min(n.Value, guessNumberMap.TryGetValue(n.Key, out var k) ? k : 0)))); + + if (new [] { PlayState.Lost, PlayState.Won }.Contains(game.PlayState)) + { + await ScoreRepository.AddScoreAsync(game); + if (!GameRepository.TryRemoveGame(game)) + throw new UserException($"The finished game ({game.Id}, {game.PlayState}) could not be removed from the repository."); + } + return game; + } + } +} diff --git a/Services/ScoreRepository.cs b/Services/ScoreRepository.cs new file mode 100644 index 0000000..3413afa --- /dev/null +++ b/Services/ScoreRepository.cs @@ -0,0 +1,41 @@ +using Mastermind.Api.Data; +using Mastermind.Api.Data.Entities; +using Mastermind.Api.Models; +using Microsoft.EntityFrameworkCore; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Mastermind.Api.Services +{ + public class ScoreRepository + { + public ScoreRepository(MastermindDbContext dbContext) => DbContext = dbContext; + + public MastermindDbContext DbContext { get; } + + public async Task AddScoreAsync(Game game) + { + DbContext.Add(new Score + { + DurationInSeconds = game.ElapsedTime.TotalSeconds, + GameStarted = game.GameCreated, + GuessesMade = game.GivenGuesses.Count, + MaximumPossibleGuesses = game.Options.MaximumNumberOfPossibleGuesses, + PlayerId = game.UserId, + GameId = game.Id, + PossibleValues = game.Options.MaximumKeyValue - 1, + KeyLength = game.Options.KeyLength, + Won = game.PlayState == PlayState.Won, + AllowDuplicates = game.Options.AllowDuplicates + }); + await DbContext.SaveChangesAsync(); + } + + public async Task> GetHighScoresAsync(int entries) => + await DbContext.Scores.Where(s => s.Won).OrderByDescending(s => 10 * s.KeyLength * s.PossibleValues * (-5 * s.MaximumPossibleGuesses) * s.GuessesMade * s.DurationInSeconds) + .Take(entries) + .Select(s => new HighScore(s.Player.Username, s.DurationInSeconds, s.GameStarted.Date, s.GuessesMade, s.PossibleValues, s.MaximumPossibleGuesses, s.AllowDuplicates)) + .ToListAsync(); + } +} diff --git a/Services/UserService.cs b/Services/UserService.cs new file mode 100644 index 0000000..d05e1e3 --- /dev/null +++ b/Services/UserService.cs @@ -0,0 +1,67 @@ +using Mastermind.Api.Data; +using Mastermind.Api.Data.Entities; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Cryptography.KeyDerivation; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using System; +using System.Linq; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Threading.Tasks; + +namespace Mastermind.Api.Services +{ + public class UserService + { + public IHttpContextAccessor HttpContextAccessor { get; } + public MastermindDbContext DbContext { get; } + + public UserService(IHttpContextAccessor httpContextAccessor, MastermindDbContext dbContext) + { + HttpContextAccessor = httpContextAccessor; + DbContext = dbContext; + } + + public Guid GetCurrentUserId() => Guid.TryParse(HttpContextAccessor.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier), out var result) ? result : Guid.Empty; + + public async Task RegisterAsync(string username, string password) + { + var salt = new byte[128 / 8]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(salt); + + var user = new User + { + Username = username, + PasswordHash = Convert.ToBase64String(salt.Concat(KeyDerivation.Pbkdf2(password, salt, KeyDerivationPrf.HMACSHA1, 10000, 256 / 8)).ToArray()) + }; + DbContext.Add(user); + await DbContext.SaveChangesAsync(); + return user.Id; + } + + public async Task CheckPasswordAsync(string username, string password) + { + var user = await DbContext.Users.SingleOrDefaultAsync(u => u.Username == username); + if (user == null) + return null; + var passwordHash = Convert.FromBase64String(user.PasswordHash); + return KeyDerivation.Pbkdf2(password, passwordHash[..(128 / 8)], KeyDerivationPrf.HMACSHA1, 10000, 256 / 8).SequenceEqual(passwordHash[(128 / 8)..]) ? (Guid?)user.Id : null; + } + + public async Task HttpCookieSignInAsync(Guid userId) => + await HttpContextAccessor.HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId.ToString()) }, "basic")), new AuthenticationProperties + { + AllowRefresh = true, + IssuedUtc = DateTimeOffset.UtcNow, + IsPersistent = true, + ExpiresUtc = DateTimeOffset.UtcNow.AddHours(2) + }); + + public async Task HttpCookieSignOutAsync() => + await HttpContextAccessor.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + } +} diff --git a/Startup.cs b/Startup.cs new file mode 100644 index 0000000..aef08ae --- /dev/null +++ b/Startup.cs @@ -0,0 +1,120 @@ +using Mastermind.Api.Data; +using Mastermind.Api.Services; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerUI; +using System; +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Mastermind.Api +{ + public class Startup + { + public Startup(IConfiguration configuration) => Configuration = configuration; + + public IConfiguration Configuration { get; } + + public void ConfigureServices(IServiceCollection services) => + services.AddDbContext(options => options.UseSqlServer(Configuration.GetConnectionString(nameof(MastermindDbContext)) ?? "Server=(localdb)\\mssqllocaldb;Database=Mastermind;Trusted_Connection=True;MultipleActiveResultSets=true")) + .AddIdentityCore() + .AddEntityFrameworkStores().Services + .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(ConfigureCookieOptions).Services + .AddControllers() + .AddJsonOptions(ConfigureJsonOptions).Services + .AddSwaggerGen(o => o.SwaggerDoc("v1", new OpenApiInfo { Title = "Mastermind API", Version = "v1" })) + .AddSingleton() + .AddSingleton() + .AddScoped() + .AddScoped() + .AddScoped(); + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) => + app.UseWhen(_ => env.IsDevelopment(), dev => dev.UseDeveloperExceptionPage().UseDatabaseErrorPage()) + .UseWhen(_ => !env.IsDevelopment(), prod => prod.UseHsts()) + .Use(HandleUserException) + .UseHttpsRedirection() + .UseRouting() + .UseAuthentication() + .UseAuthorization() + .Use(HandleInvalidCookie) + .UseSwagger() + .UseSwaggerUI(ConfigureSwaggerUI) + .UseEndpoints(e => e.MapControllers()); + + private static async Task HandleUserException(HttpContext context, Func nextRequestDelegate) + { + try + { + await nextRequestDelegate(); + } + catch (UserException uex) + { + context.Response.StatusCode = (int)HttpStatusCode.BadRequest; + context.Response.ContentType = "application/json"; + + await JsonSerializer.SerializeAsync(context.Response.Body, new ProblemDetails + { + Detail = uex.Message, + Status = context.Response.StatusCode, + Title = "The was an error in the provided request.", + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1" + }, context.RequestServices.GetRequiredService>().Value); + } + } + + private static async Task HandleInvalidCookie(HttpContext context, Func nextRequestDelegate) + { + var userService = context.RequestServices.GetRequiredService(); + var userId = userService.GetCurrentUserId(); + if (context.User.Identity.IsAuthenticated && await context.RequestServices.GetRequiredService().Users.AllAsync(u => u.Id != userId)) + await userService.HttpCookieSignOutAsync(); + await nextRequestDelegate(); + } + + private static void ConfigureJsonOptions(JsonOptions options) + { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + } + + + private static void ConfigureCookieOptions(CookieAuthenticationOptions options) => + options.Events = new CookieAuthenticationEvents + { + OnRedirectToLogin = c => + { + c.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + return Task.CompletedTask; + }, + OnRedirectToAccessDenied = c => + { + c.Response.StatusCode = (int)HttpStatusCode.Forbidden; + return Task.CompletedTask; + } + }; + + private static void ConfigureSwaggerUI(SwaggerUIOptions options) + { + options.DefaultModelRendering(ModelRendering.Model); + options.DisplayRequestDuration(); + options.DocExpansion(DocExpansion.List); + options.EnableDeepLinking(); + options.SwaggerEndpoint("/swagger/v1/swagger.json", "Mastermind API V1"); + } + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..c8e7670 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,13 @@ +{ + "ConnectionStrings": { + "MastermindDbdContext": "Server=(localdb)\\mssqllocaldb;Database=Mastermind;Trusted_Connection=True;MultipleActiveResultSets=true" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +}