From e54b9d2ce895149c2f66fd22e54585c30759e707 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Tue, 24 Feb 2026 22:13:34 +0100 Subject: [PATCH] Implement core backend domain and API workflows --- README.md | 10 + RpgRoller.Tests/GameServiceTests.cs | 247 +++++++++ RpgRoller.Tests/UnitTest1.cs | 264 +++++++-- RpgRoller.Tests/coverlet.runsettings | 1 + RpgRoller/Contracts/ApiContracts.cs | 50 +- RpgRoller/Domain/GameModels.cs | 68 +++ RpgRoller/Program.cs | 279 +++++++++- RpgRoller/Services/DiceRules.cs | 126 ++++- RpgRoller/Services/GameService.cs | 722 +++++++++++++++++++++++++ RpgRoller/Services/IGameService.cs | 32 ++ RpgRoller/Services/RandomDiceRoller.cs | 2 +- RpgRoller/Services/ServiceResult.cs | 30 + TECH.md | 1 + 13 files changed, 1769 insertions(+), 63 deletions(-) create mode 100644 RpgRoller.Tests/GameServiceTests.cs create mode 100644 RpgRoller/Domain/GameModels.cs create mode 100644 RpgRoller/Services/GameService.cs create mode 100644 RpgRoller/Services/IGameService.cs create mode 100644 RpgRoller/Services/ServiceResult.cs diff --git a/README.md b/README.md index 758347b..ad347be 100644 --- a/README.md +++ b/README.md @@ -43,3 +43,13 @@ Fresh full-stack starter scaffold: ```powershell pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70 ``` + +## Implemented Backend Scope + +- Auth: register, login, logout, current user context +- Rulesets: d6 and dnd5e validation rules +- Campaigns: create/list/read +- Characters: create/update/activate/current-campaign list +- Skills: create/update with ruleset-aware dice expression validation +- Rolls: public/private skill rolls with append-only campaign log +- State stream: SSE endpoint for campaign version updates diff --git a/RpgRoller.Tests/GameServiceTests.cs b/RpgRoller.Tests/GameServiceTests.cs new file mode 100644 index 0000000..de272de --- /dev/null +++ b/RpgRoller.Tests/GameServiceTests.cs @@ -0,0 +1,247 @@ +using Microsoft.AspNetCore.Identity; +using RpgRoller.Contracts; +using RpgRoller.Domain; +using RpgRoller.Services; + +namespace RpgRoller.Tests; + +public sealed class GameServiceTests +{ + [Fact] + public void Register_ValidatesRequiredFieldsAndDuplicates() + { + var service = CreateService(); + + var invalidUsername = service.Register(new RegisterRequest("", "Password123", "Display")); + var invalidDisplay = service.Register(new RegisterRequest("user", "Password123", "")); + var invalidPassword = service.Register(new RegisterRequest("user", "short", "Display")); + var valid = service.Register(new RegisterRequest("user", "Password123", "Display")); + var duplicate = service.Register(new RegisterRequest("user", "Password123", "Display 2")); + + Assert.False(invalidUsername.Succeeded); + Assert.False(invalidDisplay.Succeeded); + Assert.False(invalidPassword.Succeeded); + Assert.True(valid.Succeeded); + Assert.False(duplicate.Succeeded); + } + + [Fact] + public void Login_ValidatesCredentialsAndSessionLookup() + { + var service = CreateService(); + service.Register(new RegisterRequest("user", "Password123", "Display")); + + var invalidUser = service.Login(new LoginRequest("missing", "Password123")); + var invalidPassword = service.Login(new LoginRequest("user", "bad-password")); + var valid = service.Login(new LoginRequest("user", "Password123")); + + Assert.False(invalidUser.Succeeded); + Assert.False(invalidPassword.Succeeded); + Assert.True(valid.Succeeded); + + var sessionUser = service.GetUserBySession(valid.Value.SessionToken); + Assert.NotNull(sessionUser); + + service.Logout(valid.Value.SessionToken); + Assert.Null(service.GetUserBySession(valid.Value.SessionToken)); + } + + [Fact] + public void CampaignAndCharacterOperations_CheckUnauthorizedAndNotFoundCases() + { + var service = CreateService(); + + var unauthorizedCampaign = service.CreateCampaign("missing", new CreateCampaignRequest("Name", "d6")); + Assert.False(unauthorizedCampaign.Succeeded); + + service.Register(new RegisterRequest("gm", "Password123", "GM")); + var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken; + var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Name", "d6"))); + + var invalidRuleset = service.CreateCampaign(gmSession, new CreateCampaignRequest("Name 2", "unknown")); + Assert.False(invalidRuleset.Succeeded); + + var noCampaignCharacter = service.CreateCharacter(gmSession, new CreateCharacterRequest("Hero", Guid.NewGuid())); + Assert.False(noCampaignCharacter.Succeeded); + + var character = GetValue(service.CreateCharacter(gmSession, new CreateCharacterRequest("Hero", campaign.Id))); + + var missingCharacterActivate = service.ActivateCharacter(gmSession, Guid.NewGuid()); + Assert.False(missingCharacterActivate.Succeeded); + + var activateSuccess = service.ActivateCharacter(gmSession, character.Id); + Assert.True(activateSuccess.Succeeded); + + var currentCharacters = service.GetCurrentCampaignCharacters(gmSession); + Assert.True(currentCharacters.Succeeded); + Assert.Single(GetValue(currentCharacters)); + + var missingCampaignGet = service.GetCampaign(gmSession, Guid.NewGuid()); + Assert.False(missingCampaignGet.Succeeded); + } + + [Fact] + public void CharacterSkillAndRollOperations_CheckAuthorizationAndValidationBranches() + { + var service = CreateService(3, 4, 5, 6); + service.Register(new RegisterRequest("gm", "Password123", "GM")); + service.Register(new RegisterRequest("owner", "Password123", "Owner")); + service.Register(new RegisterRequest("other", "Password123", "Other")); + + var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken; + var ownerSession = GetValue(service.Login(new LoginRequest("owner", "Password123"))).SessionToken; + var otherSession = GetValue(service.Login(new LoginRequest("other", "Password123"))).SessionToken; + + var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Main", "dnd5e"))); + var character = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterRequest("Owner Char", campaign.Id))); + + var noPermissionUpdate = service.UpdateCharacter(otherSession, character.Id, new UpdateCharacterRequest("Renamed", campaign.Id)); + Assert.False(noPermissionUpdate.Succeeded); + + var invalidCharacterName = service.UpdateCharacter(ownerSession, character.Id, new UpdateCharacterRequest("", campaign.Id)); + Assert.False(invalidCharacterName.Succeeded); + + var missingTargetCampaign = service.UpdateCharacter(ownerSession, character.Id, new UpdateCharacterRequest("Renamed", Guid.NewGuid())); + Assert.False(missingTargetCampaign.Succeeded); + + var noSkillName = service.CreateSkill(ownerSession, character.Id, new CreateSkillRequest("", "1d20")); + Assert.False(noSkillName.Succeeded); + + var invalidExpression = service.CreateSkill(ownerSession, character.Id, new CreateSkillRequest("Skill", "5D+4")); + Assert.False(invalidExpression.Succeeded); + + var skill = GetValue(service.CreateSkill(ownerSession, character.Id, new CreateSkillRequest("Skill", "1d20+2"))); + + var missingSkillUpdate = service.UpdateSkill(ownerSession, Guid.NewGuid(), new UpdateSkillRequest("X", "1d20")); + Assert.False(missingSkillUpdate.Succeeded); + + var forbiddenSkillUpdate = service.UpdateSkill(otherSession, skill.Id, new UpdateSkillRequest("X", "1d20")); + Assert.False(forbiddenSkillUpdate.Succeeded); + + var gmSkillUpdate = service.UpdateSkill(gmSession, skill.Id, new UpdateSkillRequest("GM Edit", "2d6+1")); + Assert.True(gmSkillUpdate.Succeeded); + + var missingRoll = service.RollSkill(ownerSession, Guid.NewGuid(), new RollSkillRequest("public")); + Assert.False(missingRoll.Succeeded); + + var invalidVisibility = service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("hidden")); + Assert.False(invalidVisibility.Succeeded); + + var forbiddenRoll = service.RollSkill(otherSession, skill.Id, new RollSkillRequest("public")); + Assert.False(forbiddenRoll.Succeeded); + + var privateRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("private")); + var publicRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("public")); + Assert.True(privateRoll.Succeeded); + Assert.True(publicRoll.Succeeded); + + var ownerLog = service.GetCampaignLog(ownerSession, campaign.Id); + var gmLog = service.GetCampaignLog(gmSession, campaign.Id); + var outsiderLog = service.GetCampaignLog(otherSession, campaign.Id); + + Assert.Equal(2, GetValue(ownerLog).Count); + Assert.Equal(2, GetValue(gmLog).Count); + Assert.False(outsiderLog.Succeeded); + + var version = service.GetCampaignVersion(ownerSession, campaign.Id); + var missingVersion = service.GetCampaignVersion(ownerSession, Guid.NewGuid()); + Assert.True(version.Succeeded); + Assert.False(missingVersion.Succeeded); + } + + [Fact] + public void CurrentCampaignCharacters_ReturnsNoActiveCharacterWhenUnset() + { + var service = CreateService(); + service.Register(new RegisterRequest("user", "Password123", "User")); + var sessionToken = GetValue(service.Login(new LoginRequest("user", "Password123"))).SessionToken; + + var result = service.GetCurrentCampaignCharacters(sessionToken); + Assert.False(result.Succeeded); + } + + [Fact] + public void GetCampaigns_ReturnsOwnedAndParticipatingCampaigns() + { + var service = CreateService(); + service.Register(new RegisterRequest("gm", "Password123", "GM")); + service.Register(new RegisterRequest("player", "Password123", "Player")); + var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken; + var playerSession = GetValue(service.Login(new LoginRequest("player", "Password123"))).SessionToken; + + var gmCampaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Owned", "d6"))); + _ = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Owned 2", "d6"))); + _ = service.CreateCharacter(playerSession, new CreateCharacterRequest("Joiner", gmCampaign.Id)); + + var playerCampaigns = service.GetCampaigns(playerSession); + Assert.True(playerCampaigns.Succeeded); + var campaigns = GetValue(playerCampaigns); + Assert.Single(campaigns); + Assert.Equal(gmCampaign.Id, campaigns[0].Id); + } + + [Fact] + public void DiceRules_CoversParsingAndMappingBranches() + { + Assert.Equal(RulesetKind.D6, DiceRules.TryParseRulesetId("d6")); + Assert.Equal(RulesetKind.Dnd5e, DiceRules.TryParseRulesetId("dnd5e")); + Assert.Null(DiceRules.TryParseRulesetId("unknown")); + + var d6 = DiceRules.ParseExpression(RulesetKind.D6, "5D+4"); + var dnd = DiceRules.ParseExpression(RulesetKind.Dnd5e, "2d12+2"); + var badFormat = DiceRules.ParseExpression(RulesetKind.Dnd5e, "abc"); + var tooManyDice = DiceRules.ParseExpression(RulesetKind.D6, "51D+1"); + var tooManySides = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d1001"); + var tooLargeModifier = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d20+1001"); + + Assert.True(d6.Succeeded); + Assert.True(dnd.Succeeded); + Assert.False(badFormat.Succeeded); + Assert.False(tooManyDice.Succeeded); + Assert.False(tooManySides.Succeeded); + Assert.False(tooLargeModifier.Succeeded); + + Assert.Equal("d6", DiceRules.ToRulesetId(RulesetKind.D6)); + Assert.Equal("dnd5e", DiceRules.ToRulesetId(RulesetKind.Dnd5e)); + Assert.Throws(() => DiceRules.ToRulesetId((RulesetKind)99)); + } + + [Fact] + public void RandomDiceRoller_ProducesValueWithinRange() + { + var roller = new RandomDiceRoller(); + for (var i = 0; i < 64; i += 1) + { + var value = roller.Roll(12); + Assert.InRange(value, 1, 12); + } + } + + private static GameService CreateService(params int[] rollValues) + { + return new GameService(new PasswordHasher(), new FixedDiceRoller(rollValues)); + } + + private static T GetValue(ServiceResult result) + { + Assert.True(result.Succeeded); + Assert.NotNull(result.Value); + return result.Value!; + } + + private sealed class FixedDiceRoller : IDiceRoller + { + private readonly Queue m_Values; + + public FixedDiceRoller(IEnumerable values) + { + m_Values = new Queue(values); + } + + public int Roll(int sides) + { + var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1; + return Math.Clamp(next, 1, sides); + } + } +} diff --git a/RpgRoller.Tests/UnitTest1.cs b/RpgRoller.Tests/UnitTest1.cs index e08cf5a..5bd0677 100644 --- a/RpgRoller.Tests/UnitTest1.cs +++ b/RpgRoller.Tests/UnitTest1.cs @@ -10,80 +10,254 @@ namespace RpgRoller.Tests; public sealed class UnitTest1 : IClassFixture> { - private readonly HttpClient m_Client; + private readonly WebApplicationFactory m_BaseFactory; public UnitTest1(WebApplicationFactory factory) { - m_Client = factory.WithWebHostBuilder(builder => + m_BaseFactory = factory; + } + + [Fact] + public async Task RegisterLoginAndMeFlow_WorksWithDuplicateUsernameGuard() + { + using var factory = CreateFactory(4, 4, 4); + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + var registerResult = await RegisterAsync(client, "alice", "Password123", "Alice"); + Assert.Equal("alice", registerResult.Username); + + var duplicate = await client.PostAsJsonAsync("/api/auth/register", new RegisterRequest("alice", "Password123", "Alice 2")); + Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode); + + var loginResult = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "Password123")); + Assert.Equal(HttpStatusCode.OK, loginResult.StatusCode); + + var me = await GetAsync(client, "/api/me"); + Assert.Equal(registerResult.Id, me.User.Id); + Assert.Null(me.ActiveCharacterId); + Assert.Null(me.CurrentCampaignId); + + var invalidLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "wrong-password")); + Assert.Equal(HttpStatusCode.BadRequest, invalidLogin.StatusCode); + } + + [Fact] + public async Task CampaignCharacterAndSkillFlow_EnforcesRulesetValidation() + { + using var factory = CreateFactory(6, 6, 6); + using var gmClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + await RegisterAsync(gmClient, "gm", "Password123", "Game Master"); + await LoginAsync(gmClient, "gm", "Password123"); + + var campaign = await PostAsync( + gmClient, + "/api/campaigns", + new CreateCampaignRequest("Alpha Campaign", "dnd5e")); + + var gmCharacter = await PostAsync( + gmClient, + "/api/characters", + new CreateCharacterRequest("Arin", campaign.Id)); + + var activateResponse = await gmClient.PostAsync($"/api/characters/{gmCharacter.Id}/activate", null); + Assert.Equal(HttpStatusCode.OK, activateResponse.StatusCode); + + var createdSkill = await PostAsync( + gmClient, + $"/api/characters/{gmCharacter.Id}/skills", + new CreateSkillRequest("Arcana", "2d12+2")); + Assert.Equal("2d12+2", createdSkill.DiceRollDefinition); + + var invalidSkill = await gmClient.PostAsJsonAsync( + $"/api/characters/{gmCharacter.Id}/skills", + new CreateSkillRequest("Broken", "5D+4")); + Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode); + + var details = await GetAsync(gmClient, $"/api/campaigns/{campaign.Id}"); + Assert.Equal(campaign.Id, details.Id); + Assert.Single(details.Characters); + Assert.Single(details.Skills); + + var currentCampaignCharacters = await GetAsync>(gmClient, "/api/characters/current-campaign"); + Assert.Single(currentCampaignCharacters); + Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id); + + var otherCampaign = await PostAsync( + gmClient, + "/api/campaigns", + new CreateCampaignRequest("Beta Campaign", "d6")); + + var updatedCharacter = await PutAsync( + gmClient, + $"/api/characters/{gmCharacter.Id}", + new UpdateCharacterRequest("Arin Updated", otherCampaign.Id)); + + Assert.Equal("Arin Updated", updatedCharacter.Name); + Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId); + } + + [Fact] + public async Task RollVisibilityAndAuthorization_AreEnforced() + { + using var factory = CreateFactory(4, 3, 5, 2, 6); + using var gmClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + using var playerClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + using var observerClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + using var outsiderClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + await RegisterAsync(gmClient, "gm", "Password123", "GM"); + await LoginAsync(gmClient, "gm", "Password123"); + var campaign = await PostAsync( + gmClient, + "/api/campaigns", + new CreateCampaignRequest("Main", "d6")); + + await RegisterAsync(playerClient, "player", "Password123", "Player"); + await LoginAsync(playerClient, "player", "Password123"); + var playerCharacter = await PostAsync( + playerClient, + "/api/characters", + new CreateCharacterRequest("Rogue", campaign.Id)); + + var skill = await PostAsync( + playerClient, + $"/api/characters/{playerCharacter.Id}/skills", + new CreateSkillRequest("Stealth", "2D+1")); + + await RegisterAsync(observerClient, "observer", "Password123", "Observer"); + await LoginAsync(observerClient, "observer", "Password123"); + await PostAsync( + observerClient, + "/api/characters", + new CreateCharacterRequest("Watcher", campaign.Id)); + + var privateRoll = await PostAsync( + playerClient, + $"/api/skills/{skill.Id}/roll", + new RollSkillRequest("private")); + var publicRoll = await PostAsync( + playerClient, + $"/api/skills/{skill.Id}/roll", + new RollSkillRequest("public")); + + Assert.Equal("private", privateRoll.Visibility); + Assert.Equal("public", publicRoll.Visibility); + + var gmLog = await GetAsync>(gmClient, $"/api/campaigns/{campaign.Id}/log"); + Assert.Equal(2, gmLog.Count); + + var playerLog = await GetAsync>(playerClient, $"/api/campaigns/{campaign.Id}/log"); + Assert.Equal(2, playerLog.Count); + + var observerLog = await GetAsync>(observerClient, $"/api/campaigns/{campaign.Id}/log"); + Assert.Single(observerLog); + Assert.Equal("public", observerLog[0].Visibility); + + await RegisterAsync(outsiderClient, "outsider", "Password123", "Outsider"); + await LoginAsync(outsiderClient, "outsider", "Password123"); + + var forbiddenCampaign = await outsiderClient.GetAsync($"/api/campaigns/{campaign.Id}"); + Assert.Equal(HttpStatusCode.BadRequest, forbiddenCampaign.StatusCode); + + var invalidVisibility = await playerClient.PostAsJsonAsync( + $"/api/skills/{skill.Id}/roll", + new RollSkillRequest("hidden")); + Assert.Equal(HttpStatusCode.BadRequest, invalidVisibility.StatusCode); + + using var anonymousClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + var unauthorizedCampaignCreate = await anonymousClient.PostAsJsonAsync( + "/api/campaigns", + new CreateCampaignRequest("Nope", "d6")); + Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedCampaignCreate.StatusCode); + } + + [Fact] + public async Task RulesetAndSseEndpoints_ReturnExpectedResponses() + { + using var factory = CreateFactory(2, 2, 2); + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + var rulesets = await GetAsync>(client, "/api/rulesets"); + Assert.Equal(2, rulesets.Count); + + await RegisterAsync(client, "sse", "Password123", "Sse User"); + await LoginAsync(client, "sse", "Password123"); + + var campaign = await PostAsync( + client, + "/api/campaigns", + new CreateCampaignRequest("SSE", "d6")); + + var sseResponse = await client.GetAsync($"/api/events/state?campaignId={campaign.Id}", HttpCompletionOption.ResponseHeadersRead); + Assert.Equal(HttpStatusCode.OK, sseResponse.StatusCode); + Assert.Equal("text/event-stream", sseResponse.Content.Headers.ContentType?.MediaType); + } + + private WebApplicationFactory CreateFactory(params int[] rollValues) + { + return m_BaseFactory.WithWebHostBuilder(builder => builder.ConfigureServices(services => { services.RemoveAll(); - services.AddSingleton(new FixedDiceRoller(7)); - })).CreateClient(); + services.AddSingleton(new FixedDiceRoller(rollValues)); + })); } - [Fact] - public async Task GetHealth_ReturnsOkPayload() + private static async Task RegisterAsync(HttpClient client, string username, string password, string displayName) { - var response = await m_Client.GetAsync("/api/health"); - var payload = await response.Content.ReadFromJsonAsync(); + return await PostAsync( + client, + "/api/auth/register", + new RegisterRequest(username, password, displayName)); + } + private static async Task LoginAsync(HttpClient client, string username, string password) + { + var response = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest(username, password)); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotNull(payload); - Assert.Equal("ok", payload.Status); } - [Theory] - [InlineData(1, "Dice must have at least 2 sides.")] - [InlineData(1001, "Dice must have at most 1000 sides.")] - public async Task Roll_WithInvalidSides_ReturnsBadRequest(int sides, string expectedError) + private static async Task PostAsync(HttpClient client, string uri, TRequest payload) { - var response = await m_Client.GetAsync($"/api/roll/{sides}"); - var payload = await response.Content.ReadFromJsonAsync(); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.NotNull(payload); - Assert.Equal(expectedError, payload.Error); - } - - [Theory] - [InlineData(2)] - [InlineData(1000)] - public async Task Roll_WithValidSides_ReturnsExpectedResult(int sides) - { - var response = await m_Client.GetAsync($"/api/roll/{sides}"); - var payload = await response.Content.ReadFromJsonAsync(); - + var response = await client.PostAsJsonAsync(uri, payload); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotNull(payload); - Assert.Equal(sides, payload.Sides); - Assert.Equal(Math.Min(7, sides), payload.Value); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + return result; } - [Fact] - public void RandomDiceRoller_ProducesValueWithinRange() + private static async Task PutAsync(HttpClient client, string uri, TRequest payload) { - var roller = new RandomDiceRoller(); + var response = await client.PutAsJsonAsync(uri, payload); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + return result; + } - for (var i = 0; i < 200; i += 1) - { - var value = roller.Roll(6); - Assert.InRange(value, 1, 6); - } + private static async Task GetAsync(HttpClient client, string uri) + { + var response = await client.GetAsync(uri); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + return result; } private sealed class FixedDiceRoller : IDiceRoller { - private readonly int m_Result; + private readonly Queue m_Values; - public FixedDiceRoller(int result) + public FixedDiceRoller(IEnumerable values) { - m_Result = result; + m_Values = new Queue(values); } public int Roll(int sides) { - return Math.Min(m_Result, sides); + var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1; + return Math.Clamp(next, 1, sides); } } } diff --git a/RpgRoller.Tests/coverlet.runsettings b/RpgRoller.Tests/coverlet.runsettings index 21564f5..cbdb961 100644 --- a/RpgRoller.Tests/coverlet.runsettings +++ b/RpgRoller.Tests/coverlet.runsettings @@ -5,6 +5,7 @@ cobertura + [RpgRoller]RpgRoller.Services.* GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute diff --git a/RpgRoller/Contracts/ApiContracts.cs b/RpgRoller/Contracts/ApiContracts.cs index f883b71..77465b1 100644 --- a/RpgRoller/Contracts/ApiContracts.cs +++ b/RpgRoller/Contracts/ApiContracts.cs @@ -1,5 +1,53 @@ namespace RpgRoller.Contracts; public sealed record HealthResponse(string Status); -public sealed record RollResponse(int Sides, int Value); public sealed record ApiError(string Error); + +public sealed record RegisterRequest(string Username, string Password, string DisplayName); +public sealed record LoginRequest(string Username, string Password); + +public sealed record UserSummary(Guid Id, string Username, string DisplayName); +public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId); + +public sealed record RulesetDefinition(string Id, string Name, string DiceSyntax); + +public sealed record CreateCampaignRequest(string Name, string RulesetId); +public sealed record CampaignSummary(Guid Id, string Name, string RulesetId, Guid GmUserId); +public sealed record CampaignDetails( + Guid Id, + string Name, + string RulesetId, + UserSummary Gm, + IReadOnlyList Characters, + IReadOnlyList Skills); + +public sealed record CreateCharacterRequest(string Name, Guid CampaignId); +public sealed record UpdateCharacterRequest(string Name, Guid CampaignId); +public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid CampaignId); + +public sealed record CreateSkillRequest(string Name, string DiceRollDefinition); +public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition); +public sealed record SkillSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition); + +public sealed record RollSkillRequest(string Visibility); +public sealed record RollResult( + Guid RollId, + Guid CampaignId, + Guid CharacterId, + Guid SkillId, + Guid RollerUserId, + string Visibility, + int Result, + string Breakdown, + DateTimeOffset TimestampUtc); + +public sealed record CampaignLogEntry( + Guid RollId, + Guid CampaignId, + Guid CharacterId, + Guid SkillId, + Guid RollerUserId, + string Visibility, + int Result, + string Breakdown, + DateTimeOffset TimestampUtc); diff --git a/RpgRoller/Domain/GameModels.cs b/RpgRoller/Domain/GameModels.cs new file mode 100644 index 0000000..89e66d4 --- /dev/null +++ b/RpgRoller/Domain/GameModels.cs @@ -0,0 +1,68 @@ +namespace RpgRoller.Domain; + +public enum RulesetKind +{ + D6, + Dnd5e +} + +public enum RollVisibility +{ + Public, + Private +} + +public sealed class UserAccount +{ + public required Guid Id { get; init; } + public required string Username { get; init; } + public required string PasswordHash { get; set; } + public required string DisplayName { get; set; } +} + +public sealed class UserSession +{ + public required string Token { get; init; } + public required Guid UserId { get; init; } + public required DateTimeOffset CreatedAtUtc { get; init; } +} + +public sealed class Campaign +{ + public required Guid Id { get; init; } + public required Guid GmUserId { get; init; } + public required string Name { get; set; } + public required RulesetKind Ruleset { get; set; } + public long Version { get; set; } +} + +public sealed class Character +{ + public required Guid Id { get; init; } + public required Guid OwnerUserId { get; init; } + public required Guid CampaignId { get; set; } + public required string Name { get; set; } +} + +public sealed class Skill +{ + public required Guid Id { get; init; } + public required Guid CharacterId { get; set; } + public required string Name { get; set; } + public required string DiceRollDefinition { get; set; } +} + +public sealed class RollLogEntry +{ + public required Guid Id { get; init; } + public required Guid CampaignId { get; init; } + public required Guid CharacterId { get; init; } + public required Guid SkillId { get; init; } + public required Guid RollerUserId { get; init; } + public required RollVisibility Visibility { get; init; } + public required int Result { get; init; } + public required string Breakdown { get; init; } + public required DateTimeOffset TimestampUtc { get; init; } +} + +public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical); diff --git a/RpgRoller/Program.cs b/RpgRoller/Program.cs index 0b6d1f2..dae1949 100644 --- a/RpgRoller/Program.cs +++ b/RpgRoller/Program.cs @@ -1,9 +1,15 @@ using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Identity; using RpgRoller.Contracts; +using RpgRoller.Domain; using RpgRoller.Services; +const string SessionCookieName = "rpgroller_session"; + var builder = WebApplication.CreateBuilder(args); +builder.Services.AddSingleton, PasswordHasher>(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); var app = builder.Build(); @@ -11,21 +17,274 @@ app.UseDefaultFiles(); app.UseStaticFiles(); app.MapGet("/api/health", () => TypedResults.Ok(new HealthResponse("ok"))); +app.MapGet("/api/rulesets", (IGameService game) => TypedResults.Ok(game.GetRulesets())); -app.MapGet( - "/api/roll/{sides:int}", - Results, BadRequest> (int sides, IDiceRoller diceRoller) => +app.MapPost("/api/auth/register", Results, BadRequest> (RegisterRequest request, IGameService game) => +{ + var result = game.Register(request); + if (!result.Succeeded) { - var validationError = DiceRules.ValidateSides(sides); - if (validationError is not null) - { - return TypedResults.BadRequest(new ApiError(validationError)); - } + return ToBadRequest(result.Error!); + } - var value = diceRoller.Roll(sides); - return TypedResults.Ok(new RollResponse(sides, value)); + return TypedResults.Ok(result.Value!); +}); + +app.MapPost("/api/auth/login", Results, BadRequest> (LoginRequest request, HttpContext context, IGameService game) => +{ + var result = game.Login(request); + if (!result.Succeeded) + { + return ToBadRequest(result.Error!); + } + + context.Response.Cookies.Append(SessionCookieName, result.Value.SessionToken, new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Strict, + IsEssential = true, + Secure = false }); + return TypedResults.Ok(result.Value.User); +}); + +app.MapPost("/api/auth/logout", (HttpContext context, IGameService game) => +{ + if (TryGetSessionToken(context, out var sessionToken)) + { + game.Logout(sessionToken); + } + + context.Response.Cookies.Delete(SessionCookieName); + return TypedResults.NoContent(); +}); + +app.MapGet("/api/me", Results, BadRequest, UnauthorizedHttpResult> (HttpContext context, IGameService game) => +{ + if (!TryGetSessionToken(context, out var sessionToken)) + { + return TypedResults.Unauthorized(); + } + + var result = game.GetMe(sessionToken); + if (!result.Succeeded) + { + return result.Error!.Code == "unauthorized" + ? TypedResults.Unauthorized() + : TypedResults.BadRequest(new ApiError(result.Error.Message)); + } + + return TypedResults.Ok(result.Value!); +}); + +app.MapPost("/api/campaigns", (CreateCampaignRequest request, HttpContext context, IGameService game) => +{ + if (!TryGetSessionToken(context, out var sessionToken)) + { + return TypedResults.Unauthorized(); + } + + var result = game.CreateCampaign(sessionToken, request); + return ToApiResult(result); +}); + +app.MapGet("/api/campaigns", (HttpContext context, IGameService game) => +{ + if (!TryGetSessionToken(context, out var sessionToken)) + { + return TypedResults.Unauthorized(); + } + + var result = game.GetCampaigns(sessionToken); + return ToApiResult(result); +}); + +app.MapGet("/api/campaigns/{campaignId:guid}", (Guid campaignId, HttpContext context, IGameService game) => +{ + if (!TryGetSessionToken(context, out var sessionToken)) + { + return TypedResults.Unauthorized(); + } + + var result = game.GetCampaign(sessionToken, campaignId); + return ToApiResult(result); +}); + +app.MapGet("/api/campaigns/{campaignId:guid}/log", (Guid campaignId, HttpContext context, IGameService game) => +{ + if (!TryGetSessionToken(context, out var sessionToken)) + { + return TypedResults.Unauthorized(); + } + + var result = game.GetCampaignLog(sessionToken, campaignId); + return ToApiResult(result); +}); + +app.MapPost("/api/characters", (CreateCharacterRequest request, HttpContext context, IGameService game) => +{ + if (!TryGetSessionToken(context, out var sessionToken)) + { + return TypedResults.Unauthorized(); + } + + var result = game.CreateCharacter(sessionToken, request); + return ToApiResult(result); +}); + +app.MapPut("/api/characters/{characterId:guid}", (Guid characterId, UpdateCharacterRequest request, HttpContext context, IGameService game) => +{ + if (!TryGetSessionToken(context, out var sessionToken)) + { + return TypedResults.Unauthorized(); + } + + var result = game.UpdateCharacter(sessionToken, characterId, request); + return ToApiResult(result); +}); + +app.MapPost("/api/characters/{characterId:guid}/activate", (Guid characterId, HttpContext context, IGameService game) => +{ + if (!TryGetSessionToken(context, out var sessionToken)) + { + return TypedResults.Unauthorized(); + } + + var result = game.ActivateCharacter(sessionToken, characterId); + return ToApiResult(result); +}); + +app.MapGet("/api/characters/current-campaign", (HttpContext context, IGameService game) => +{ + if (!TryGetSessionToken(context, out var sessionToken)) + { + return TypedResults.Unauthorized(); + } + + var result = game.GetCurrentCampaignCharacters(sessionToken); + return ToApiResult(result); +}); + +app.MapPost("/api/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) => +{ + if (!TryGetSessionToken(context, out var sessionToken)) + { + return TypedResults.Unauthorized(); + } + + var result = game.CreateSkill(sessionToken, characterId, request); + return ToApiResult(result); +}); + +app.MapPut("/api/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) => +{ + if (!TryGetSessionToken(context, out var sessionToken)) + { + return TypedResults.Unauthorized(); + } + + var result = game.UpdateSkill(sessionToken, skillId, request); + return ToApiResult(result); +}); + +app.MapPost("/api/skills/{skillId:guid}/roll", (Guid skillId, RollSkillRequest request, HttpContext context, IGameService game) => +{ + if (!TryGetSessionToken(context, out var sessionToken)) + { + return TypedResults.Unauthorized(); + } + + var result = game.RollSkill(sessionToken, skillId, request); + return ToApiResult(result); +}); + +app.MapGet("/api/events/state", async Task ( + Guid campaignId, + HttpContext context, + IGameService game) => +{ + if (!TryGetSessionToken(context, out var sessionToken)) + { + return TypedResults.Unauthorized(); + } + + var versionResult = game.GetCampaignVersion(sessionToken, campaignId); + if (!versionResult.Succeeded) + { + return versionResult.Error!.Code == "unauthorized" + ? TypedResults.Unauthorized() + : TypedResults.BadRequest(new ApiError(versionResult.Error.Message)); + } + + context.Response.Headers.CacheControl = "no-cache"; + context.Response.Headers.Connection = "keep-alive"; + context.Response.ContentType = "text/event-stream"; + + var lastVersion = versionResult.Value; + await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n"); + await context.Response.Body.FlushAsync(); + + try + { + while (!context.RequestAborted.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(1), context.RequestAborted); + + var currentVersionResult = game.GetCampaignVersion(sessionToken, campaignId); + if (!currentVersionResult.Succeeded) + { + break; + } + + if (currentVersionResult.Value != lastVersion) + { + lastVersion = currentVersionResult.Value; + await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n"); + } + else + { + await context.Response.WriteAsync(": heartbeat\n\n"); + } + + await context.Response.Body.FlushAsync(); + } + } + catch (OperationCanceledException) + { + } + + return TypedResults.Empty; +}); + app.Run(); +return; + +static bool TryGetSessionToken(HttpContext context, out string sessionToken) +{ + sessionToken = context.Request.Cookies[SessionCookieName] ?? string.Empty; + return !string.IsNullOrWhiteSpace(sessionToken); +} + +static Results, BadRequest, UnauthorizedHttpResult> ToApiResult(ServiceResult result) +{ + if (result.Succeeded) + { + return TypedResults.Ok(result.Value!); + } + + if (result.Error!.Code == "unauthorized") + { + return TypedResults.Unauthorized(); + } + + return TypedResults.BadRequest(new ApiError(result.Error.Message)); +} + +static BadRequest ToBadRequest(ServiceError error) +{ + return TypedResults.BadRequest(new ApiError(error.Message)); +} + public partial class Program; diff --git a/RpgRoller/Services/DiceRules.cs b/RpgRoller/Services/DiceRules.cs index fdb6812..ed4699a 100644 --- a/RpgRoller/Services/DiceRules.cs +++ b/RpgRoller/Services/DiceRules.cs @@ -1,19 +1,133 @@ +using System.Text.RegularExpressions; +using RpgRoller.Domain; + namespace RpgRoller.Services; -public static class DiceRules +public static partial class DiceRules { - public static string? ValidateSides(int sides) + private const int MaxDiceCount = 50; + private const int MaxSides = 1000; + private const int MaxModifier = 1000; + + public static readonly IReadOnlyList<(RulesetKind Kind, string Id, string Name, string DiceSyntax)> SupportedRulesets = + [ + (RulesetKind.D6, "d6", "D6 System", "countD(+modifier), e.g. 5D+4"), + (RulesetKind.Dnd5e, "dnd5e", "D&D 5e", "countdSides(+modifier), e.g. 2d12+2") + ]; + + public static RulesetKind? TryParseRulesetId(string rulesetId) { - if (sides < 2) + if (string.Equals(rulesetId, "d6", StringComparison.OrdinalIgnoreCase)) { - return "Dice must have at least 2 sides."; + return RulesetKind.D6; } - if (sides > 1000) + if (string.Equals(rulesetId, "dnd5e", StringComparison.OrdinalIgnoreCase)) { - return "Dice must have at most 1000 sides."; + return RulesetKind.Dnd5e; } return null; } + + public static string ToRulesetId(RulesetKind ruleset) + { + return ruleset switch + { + RulesetKind.D6 => "d6", + RulesetKind.Dnd5e => "dnd5e", + _ => throw new ArgumentOutOfRangeException(nameof(ruleset), ruleset, "Unknown ruleset.") + }; + } + + public static ServiceResult ParseExpression(RulesetKind ruleset, string expression) + { + if (string.IsNullOrWhiteSpace(expression)) + { + return ServiceResult.Failure("invalid_expression", "Dice expression is required."); + } + + var trimmed = expression.Trim(); + return ruleset switch + { + RulesetKind.D6 => ParseD6(trimmed), + RulesetKind.Dnd5e => ParseDnd5e(trimmed), + _ => ServiceResult.Failure("invalid_ruleset", "Unknown ruleset.") + }; + } + + private static ServiceResult ParseD6(string expression) + { + var match = D6Regex().Match(expression); + if (!match.Success) + { + return ServiceResult.Failure("invalid_expression", "Expected d6 format like 5D+4."); + } + + var diceCount = int.Parse(match.Groups["count"].Value); + var modifier = ParseModifier(match.Groups["modifier"].Value); + var validation = ValidateDiceParts(diceCount, 6, modifier); + if (!validation.Succeeded) + { + return ServiceResult.Failure(validation.Error!.Code, validation.Error.Message); + } + + return ServiceResult.Success(new DiceExpression(diceCount, 6, modifier, $"{diceCount}D{FormatModifier(modifier)}")); + } + + private static ServiceResult ParseDnd5e(string expression) + { + var match = Dnd5eRegex().Match(expression); + if (!match.Success) + { + return ServiceResult.Failure("invalid_expression", "Expected dnd5e format like 2d12+2."); + } + + var diceCount = int.Parse(match.Groups["count"].Value); + var sides = int.Parse(match.Groups["sides"].Value); + var modifier = ParseModifier(match.Groups["modifier"].Value); + var validation = ValidateDiceParts(diceCount, sides, modifier); + if (!validation.Succeeded) + { + return ServiceResult.Failure(validation.Error!.Code, validation.Error.Message); + } + + return ServiceResult.Success(new DiceExpression(diceCount, sides, modifier, $"{diceCount}d{sides}{FormatModifier(modifier)}")); + } + + private static ServiceResult ValidateDiceParts(int diceCount, int sides, int modifier) + { + if (diceCount < 1 || diceCount > MaxDiceCount) + { + return ServiceResult.Failure("invalid_expression", $"Dice count must be between 1 and {MaxDiceCount}."); + } + + if (sides < 2 || sides > MaxSides) + { + return ServiceResult.Failure("invalid_expression", $"Dice sides must be between 2 and {MaxSides}."); + } + + if (modifier < 0 || modifier > MaxModifier) + { + return ServiceResult.Failure("invalid_expression", $"Modifier must be between 0 and {MaxModifier}."); + } + + return ServiceResult.Success(true); + } + + private static int ParseModifier(string value) + { + return string.IsNullOrEmpty(value) ? 0 : int.Parse(value); + } + + private static string FormatModifier(int modifier) + { + return modifier > 0 ? $"+{modifier}" : string.Empty; + } + + [GeneratedRegex("^(?\\d+)D(?:\\+(?\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] + private static partial Regex D6Regex(); + + [GeneratedRegex("^(?\\d+)d(?\\d+)(?:\\+(?\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] + private static partial Regex Dnd5eRegex(); } diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs new file mode 100644 index 0000000..b5d512e --- /dev/null +++ b/RpgRoller/Services/GameService.cs @@ -0,0 +1,722 @@ +using System.Collections.ObjectModel; +using Microsoft.AspNetCore.Identity; +using RpgRoller.Contracts; +using RpgRoller.Domain; + +namespace RpgRoller.Services; + +public sealed class GameService : IGameService +{ + private readonly object m_Gate = new(); + private readonly IPasswordHasher m_PasswordHasher; + private readonly IDiceRoller m_DiceRoller; + private readonly Dictionary m_UsersById = []; + private readonly Dictionary m_UserIdsByUsername = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary m_SessionsByToken = new(StringComparer.Ordinal); + private readonly Dictionary m_CampaignsById = []; + private readonly Dictionary m_CharactersById = []; + private readonly Dictionary m_SkillsById = []; + private readonly List m_RollLog = []; + private readonly Dictionary m_ActiveCharacterByUserId = []; + + public GameService(IPasswordHasher passwordHasher, IDiceRoller diceRoller) + { + m_PasswordHasher = passwordHasher; + m_DiceRoller = diceRoller; + } + + public IReadOnlyList GetRulesets() + { + return DiceRules.SupportedRulesets + .Select(r => new RulesetDefinition(r.Id, r.Name, r.DiceSyntax)) + .ToArray(); + } + + public ServiceResult Register(RegisterRequest request) + { + if (string.IsNullOrWhiteSpace(request.Username)) + { + return ServiceResult.Failure("invalid_username", "Username is required."); + } + + if (string.IsNullOrWhiteSpace(request.DisplayName)) + { + return ServiceResult.Failure("invalid_display_name", "Display name is required."); + } + + if (string.IsNullOrWhiteSpace(request.Password) || request.Password.Length < 8) + { + return ServiceResult.Failure("invalid_password", "Password must be at least 8 characters."); + } + + lock (m_Gate) + { + if (m_UserIdsByUsername.ContainsKey(request.Username)) + { + return ServiceResult.Failure("duplicate_username", "Username is already taken."); + } + + var user = new UserAccount + { + Id = Guid.NewGuid(), + Username = request.Username.Trim(), + DisplayName = request.DisplayName.Trim(), + PasswordHash = string.Empty + }; + + user.PasswordHash = m_PasswordHasher.HashPassword(user, request.Password); + + m_UsersById[user.Id] = user; + m_UserIdsByUsername[user.Username] = user.Id; + + return ServiceResult.Success(ToUserSummary(user)); + } + } + + public ServiceResult<(UserSummary User, string SessionToken)> Login(LoginRequest request) + { + if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) + { + return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password."); + } + + lock (m_Gate) + { + if (!m_UserIdsByUsername.TryGetValue(request.Username, out var userId)) + { + return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password."); + } + + var user = m_UsersById[userId]; + var verification = m_PasswordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password); + if (verification == PasswordVerificationResult.Failed) + { + return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password."); + } + + if (verification == PasswordVerificationResult.SuccessRehashNeeded) + { + user.PasswordHash = m_PasswordHasher.HashPassword(user, request.Password); + } + + var session = CreateSession(userId); + return ServiceResult<(UserSummary User, string SessionToken)>.Success((ToUserSummary(user), session.Token)); + } + } + + public void Logout(string sessionToken) + { + lock (m_Gate) + { + m_SessionsByToken.Remove(sessionToken); + } + } + + public UserSummary? GetUserBySession(string sessionToken) + { + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + return user is null ? null : ToUserSummary(user); + } + } + + public ServiceResult GetMe(string sessionToken) + { + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult.Failure("unauthorized", "You must be logged in."); + } + + m_ActiveCharacterByUserId.TryGetValue(user.Id, out var activeCharacterId); + var campaignId = activeCharacterId != Guid.Empty && m_CharactersById.TryGetValue(activeCharacterId, out var activeCharacter) + ? activeCharacter.CampaignId + : (Guid?)null; + + return ServiceResult.Success(new MeResponse(ToUserSummary(user), activeCharacterId == Guid.Empty ? null : activeCharacterId, campaignId)); + } + } + + public ServiceResult CreateCampaign(string sessionToken, CreateCampaignRequest request) + { + if (string.IsNullOrWhiteSpace(request.Name)) + { + return ServiceResult.Failure("invalid_campaign_name", "Campaign name is required."); + } + + var ruleset = DiceRules.TryParseRulesetId(request.RulesetId); + if (ruleset is null) + { + return ServiceResult.Failure("invalid_ruleset", "Unknown ruleset."); + } + + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult.Failure("unauthorized", "You must be logged in."); + } + + var campaign = new Campaign + { + Id = Guid.NewGuid(), + GmUserId = user.Id, + Name = request.Name.Trim(), + Ruleset = ruleset.Value, + Version = 1 + }; + + m_CampaignsById[campaign.Id] = campaign; + return ServiceResult.Success(ToCampaignSummary(campaign)); + } + } + + public ServiceResult> GetCampaigns(string sessionToken) + { + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult>.Failure("unauthorized", "You must be logged in."); + } + + var campaignIds = new HashSet(m_CampaignsById.Values.Where(c => c.GmUserId == user.Id).Select(c => c.Id)); + foreach (var character in m_CharactersById.Values.Where(c => c.OwnerUserId == user.Id)) + { + campaignIds.Add(character.CampaignId); + } + + var results = campaignIds + .Select(id => m_CampaignsById[id]) + .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .Select(ToCampaignSummary) + .ToArray(); + + return ServiceResult>.Success(results); + } + } + + public ServiceResult GetCampaign(string sessionToken, Guid campaignId) + { + lock (m_Gate) + { + var context = ResolveContextLocked(sessionToken, campaignId); + if (!context.Succeeded) + { + return ServiceResult.Failure(context.Error!.Code, context.Error.Message); + } + + var (user, campaign) = context.Value!; + var gm = m_UsersById[campaign.GmUserId]; + var isGm = campaign.GmUserId == user.Id; + + var characters = m_CharactersById.Values + .Where(c => c.CampaignId == campaign.Id) + .Where(c => isGm || c.OwnerUserId == user.Id) + .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .Select(ToCharacterSummary) + .ToArray(); + + var visibleCharacterIds = characters.Select(c => c.Id).ToHashSet(); + + var skills = m_SkillsById.Values + .Where(s => visibleCharacterIds.Contains(s.CharacterId)) + .OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase) + .Select(ToSkillSummary) + .ToArray(); + + return ServiceResult.Success(new CampaignDetails( + campaign.Id, + campaign.Name, + DiceRules.ToRulesetId(campaign.Ruleset), + ToUserSummary(gm), + characters, + skills)); + } + } + + public ServiceResult CreateCharacter(string sessionToken, CreateCharacterRequest request) + { + if (string.IsNullOrWhiteSpace(request.Name)) + { + return ServiceResult.Failure("invalid_character_name", "Character name is required."); + } + + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult.Failure("unauthorized", "You must be logged in."); + } + + if (!m_CampaignsById.ContainsKey(request.CampaignId)) + { + return ServiceResult.Failure("campaign_not_found", "Campaign was not found."); + } + + var character = new Character + { + Id = Guid.NewGuid(), + OwnerUserId = user.Id, + CampaignId = request.CampaignId, + Name = request.Name.Trim() + }; + + m_CharactersById[character.Id] = character; + TouchCampaignLocked(character.CampaignId); + + return ServiceResult.Success(ToCharacterSummary(character)); + } + } + + public ServiceResult UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterRequest request) + { + if (string.IsNullOrWhiteSpace(request.Name)) + { + return ServiceResult.Failure("invalid_character_name", "Character name is required."); + } + + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult.Failure("unauthorized", "You must be logged in."); + } + + if (!m_CharactersById.TryGetValue(characterId, out var character)) + { + return ServiceResult.Failure("character_not_found", "Character was not found."); + } + + if (!m_CampaignsById.TryGetValue(request.CampaignId, out var targetCampaign)) + { + return ServiceResult.Failure("campaign_not_found", "Campaign was not found."); + } + + var sourceCampaign = m_CampaignsById[character.CampaignId]; + var isOwner = character.OwnerUserId == user.Id; + var isSourceGm = sourceCampaign.GmUserId == user.Id; + var isTargetGm = targetCampaign.GmUserId == user.Id; + if (!isOwner && !isSourceGm && !isTargetGm) + { + return ServiceResult.Failure("forbidden", "Only the owner or GM can edit this character."); + } + + var sourceCampaignId = character.CampaignId; + character.Name = request.Name.Trim(); + character.CampaignId = request.CampaignId; + + TouchCampaignLocked(sourceCampaignId); + TouchCampaignLocked(character.CampaignId); + + return ServiceResult.Success(ToCharacterSummary(character)); + } + } + + public ServiceResult ActivateCharacter(string sessionToken, Guid characterId) + { + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult.Failure("unauthorized", "You must be logged in."); + } + + if (!m_CharactersById.TryGetValue(characterId, out var character)) + { + return ServiceResult.Failure("character_not_found", "Character was not found."); + } + + if (character.OwnerUserId != user.Id) + { + return ServiceResult.Failure("forbidden", "You can activate only your own character."); + } + + m_ActiveCharacterByUserId[user.Id] = character.Id; + return ServiceResult.Success(true); + } + } + + public ServiceResult> GetCurrentCampaignCharacters(string sessionToken) + { + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult>.Failure("unauthorized", "You must be logged in."); + } + + if (!TryGetCurrentCampaignIdLocked(user.Id, out var campaignId)) + { + return ServiceResult>.Failure("no_active_character", "No active character is selected."); + } + + var characters = m_CharactersById.Values + .Where(c => c.CampaignId == campaignId && c.OwnerUserId == user.Id) + .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .Select(ToCharacterSummary) + .ToArray(); + + return ServiceResult>.Success(characters); + } + } + + public ServiceResult CreateSkill(string sessionToken, Guid characterId, CreateSkillRequest request) + { + if (string.IsNullOrWhiteSpace(request.Name)) + { + return ServiceResult.Failure("invalid_skill_name", "Skill name is required."); + } + + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult.Failure("unauthorized", "You must be logged in."); + } + + if (!m_CharactersById.TryGetValue(characterId, out var character)) + { + return ServiceResult.Failure("character_not_found", "Character was not found."); + } + + var campaign = m_CampaignsById[character.CampaignId]; + if (!CanEditCharacterLocked(user.Id, character, campaign)) + { + return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); + } + + var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, request.DiceRollDefinition); + if (!expressionValidation.Succeeded) + { + return ServiceResult.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); + } + + var skill = new Skill + { + Id = Guid.NewGuid(), + CharacterId = character.Id, + Name = request.Name.Trim(), + DiceRollDefinition = expressionValidation.Value!.Canonical + }; + + m_SkillsById[skill.Id] = skill; + TouchCampaignLocked(campaign.Id); + + return ServiceResult.Success(ToSkillSummary(skill)); + } + } + + public ServiceResult UpdateSkill(string sessionToken, Guid skillId, UpdateSkillRequest request) + { + if (string.IsNullOrWhiteSpace(request.Name)) + { + return ServiceResult.Failure("invalid_skill_name", "Skill name is required."); + } + + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult.Failure("unauthorized", "You must be logged in."); + } + + if (!m_SkillsById.TryGetValue(skillId, out var skill)) + { + return ServiceResult.Failure("skill_not_found", "Skill was not found."); + } + + var character = m_CharactersById[skill.CharacterId]; + var campaign = m_CampaignsById[character.CampaignId]; + if (!CanEditCharacterLocked(user.Id, character, campaign)) + { + return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); + } + + var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, request.DiceRollDefinition); + if (!expressionValidation.Succeeded) + { + return ServiceResult.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); + } + + skill.Name = request.Name.Trim(); + skill.DiceRollDefinition = expressionValidation.Value!.Canonical; + TouchCampaignLocked(campaign.Id); + + return ServiceResult.Success(ToSkillSummary(skill)); + } + } + + public ServiceResult RollSkill(string sessionToken, Guid skillId, RollSkillRequest request) + { + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult.Failure("unauthorized", "You must be logged in."); + } + + if (!m_SkillsById.TryGetValue(skillId, out var skill)) + { + return ServiceResult.Failure("skill_not_found", "Skill was not found."); + } + + var character = m_CharactersById[skill.CharacterId]; + var campaign = m_CampaignsById[character.CampaignId]; + if (!CanEditCharacterLocked(user.Id, character, campaign)) + { + return ServiceResult.Failure("forbidden", "Only the owner or GM can roll this skill."); + } + + var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, skill.DiceRollDefinition); + if (!parsedExpression.Succeeded) + { + return ServiceResult.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message); + } + + var visibility = ParseVisibility(request.Visibility); + if (!visibility.Succeeded) + { + return ServiceResult.Failure(visibility.Error!.Code, visibility.Error.Message); + } + + var roll = ComputeRoll(parsedExpression.Value!); + var entry = new RollLogEntry + { + Id = Guid.NewGuid(), + CampaignId = campaign.Id, + CharacterId = character.Id, + SkillId = skill.Id, + RollerUserId = user.Id, + Visibility = visibility.Value, + Result = roll.Total, + Breakdown = roll.Breakdown, + TimestampUtc = DateTimeOffset.UtcNow + }; + + m_RollLog.Add(entry); + TouchCampaignLocked(campaign.Id); + + return ServiceResult.Success(ToRollResult(entry)); + } + } + + public ServiceResult> GetCampaignLog(string sessionToken, Guid campaignId) + { + lock (m_Gate) + { + var context = ResolveContextLocked(sessionToken, campaignId); + if (!context.Succeeded) + { + return ServiceResult>.Failure(context.Error!.Code, context.Error.Message); + } + + var (user, campaign) = context.Value!; + var entries = m_RollLog + .Where(r => r.CampaignId == campaign.Id) + .Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || campaign.GmUserId == user.Id) + .OrderBy(r => r.TimestampUtc) + .ThenBy(r => r.Id) + .Select(ToLogEntry) + .ToArray(); + + return ServiceResult>.Success(entries); + } + } + + public ServiceResult GetCampaignVersion(string sessionToken, Guid campaignId) + { + lock (m_Gate) + { + var context = ResolveContextLocked(sessionToken, campaignId); + if (!context.Succeeded) + { + return ServiceResult.Failure(context.Error!.Code, context.Error.Message); + } + + return ServiceResult.Success(context.Value!.Campaign.Version); + } + } + + private (int Total, string Breakdown) ComputeRoll(DiceExpression expression) + { + var diceValues = new int[expression.DiceCount]; + var total = expression.Modifier; + for (var i = 0; i < expression.DiceCount; i += 1) + { + var value = m_DiceRoller.Roll(expression.Sides); + diceValues[i] = value; + total += value; + } + + var modifierPart = expression.Modifier > 0 ? $"+{expression.Modifier}" : string.Empty; + var breakdown = $"{string.Join("+", diceValues)}{modifierPart}={total}"; + return (total, breakdown); + } + + private ServiceResult ParseVisibility(string visibility) + { + if (string.Equals(visibility, "public", StringComparison.OrdinalIgnoreCase)) + { + return ServiceResult.Success(RollVisibility.Public); + } + + if (string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase)) + { + return ServiceResult.Success(RollVisibility.Private); + } + + return ServiceResult.Failure("invalid_visibility", "Visibility must be 'public' or 'private'."); + } + + private ServiceResult<(UserAccount User, Campaign Campaign)> ResolveContextLocked(string sessionToken, Guid campaignId) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("unauthorized", "You must be logged in."); + } + + if (!m_CampaignsById.TryGetValue(campaignId, out var campaign)) + { + return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("campaign_not_found", "Campaign was not found."); + } + + if (!CanViewCampaignLocked(user.Id, campaign.Id)) + { + return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("forbidden", "You are not a participant in this campaign."); + } + + return ServiceResult<(UserAccount User, Campaign Campaign)>.Success((user, campaign)); + } + + private static UserSummary ToUserSummary(UserAccount user) + { + return new UserSummary(user.Id, user.Username, user.DisplayName); + } + + private static CampaignSummary ToCampaignSummary(Campaign campaign) + { + return new CampaignSummary(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), campaign.GmUserId); + } + + private static CharacterSummary ToCharacterSummary(Character character) + { + return new CharacterSummary(character.Id, character.Name, character.OwnerUserId, character.CampaignId); + } + + private static SkillSummary ToSkillSummary(Skill skill) + { + return new SkillSummary(skill.Id, skill.CharacterId, skill.Name, skill.DiceRollDefinition); + } + + private static RollResult ToRollResult(RollLogEntry entry) + { + return new RollResult( + entry.Id, + entry.CampaignId, + entry.CharacterId, + entry.SkillId, + entry.RollerUserId, + entry.Visibility == RollVisibility.Public ? "public" : "private", + entry.Result, + entry.Breakdown, + entry.TimestampUtc); + } + + private static CampaignLogEntry ToLogEntry(RollLogEntry entry) + { + return new CampaignLogEntry( + entry.Id, + entry.CampaignId, + entry.CharacterId, + entry.SkillId, + entry.RollerUserId, + entry.Visibility == RollVisibility.Public ? "public" : "private", + entry.Result, + entry.Breakdown, + entry.TimestampUtc); + } + + private bool CanEditCharacterLocked(Guid actorUserId, Character character, Campaign campaign) + { + return character.OwnerUserId == actorUserId || campaign.GmUserId == actorUserId; + } + + private bool CanViewCampaignLocked(Guid userId, Guid campaignId) + { + var campaign = m_CampaignsById[campaignId]; + if (campaign.GmUserId == userId) + { + return true; + } + + return m_CharactersById.Values.Any(c => c.CampaignId == campaignId && c.OwnerUserId == userId); + } + + private bool TryGetCurrentCampaignIdLocked(Guid userId, out Guid campaignId) + { + campaignId = Guid.Empty; + if (!m_ActiveCharacterByUserId.TryGetValue(userId, out var activeCharacterId)) + { + return false; + } + + if (!m_CharactersById.TryGetValue(activeCharacterId, out var character)) + { + return false; + } + + campaignId = character.CampaignId; + return true; + } + + private UserSession CreateSession(Guid userId) + { + var token = Guid.NewGuid().ToString("N"); + var session = new UserSession + { + Token = token, + UserId = userId, + CreatedAtUtc = DateTimeOffset.UtcNow + }; + + m_SessionsByToken[token] = session; + return session; + } + + private UserAccount? ResolveUserLocked(string sessionToken) + { + if (string.IsNullOrWhiteSpace(sessionToken)) + { + return null; + } + + if (!m_SessionsByToken.TryGetValue(sessionToken, out var session)) + { + return null; + } + + return m_UsersById.GetValueOrDefault(session.UserId); + } + + private void TouchCampaignLocked(Guid campaignId) + { + if (m_CampaignsById.TryGetValue(campaignId, out var campaign)) + { + campaign.Version += 1; + } + } +} diff --git a/RpgRoller/Services/IGameService.cs b/RpgRoller/Services/IGameService.cs new file mode 100644 index 0000000..473ddef --- /dev/null +++ b/RpgRoller/Services/IGameService.cs @@ -0,0 +1,32 @@ +using RpgRoller.Contracts; +using RpgRoller.Domain; + +namespace RpgRoller.Services; + +public interface IGameService +{ + IReadOnlyList GetRulesets(); + + ServiceResult Register(RegisterRequest request); + ServiceResult<(UserSummary User, string SessionToken)> Login(LoginRequest request); + void Logout(string sessionToken); + UserSummary? GetUserBySession(string sessionToken); + ServiceResult GetMe(string sessionToken); + + ServiceResult CreateCampaign(string sessionToken, CreateCampaignRequest request); + ServiceResult> GetCampaigns(string sessionToken); + ServiceResult GetCampaign(string sessionToken, Guid campaignId); + + ServiceResult CreateCharacter(string sessionToken, CreateCharacterRequest request); + ServiceResult UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterRequest request); + ServiceResult ActivateCharacter(string sessionToken, Guid characterId); + ServiceResult> GetCurrentCampaignCharacters(string sessionToken); + + ServiceResult CreateSkill(string sessionToken, Guid characterId, CreateSkillRequest request); + ServiceResult UpdateSkill(string sessionToken, Guid skillId, UpdateSkillRequest request); + + ServiceResult RollSkill(string sessionToken, Guid skillId, RollSkillRequest request); + ServiceResult> GetCampaignLog(string sessionToken, Guid campaignId); + + ServiceResult GetCampaignVersion(string sessionToken, Guid campaignId); +} diff --git a/RpgRoller/Services/RandomDiceRoller.cs b/RpgRoller/Services/RandomDiceRoller.cs index e366047..b59a593 100644 --- a/RpgRoller/Services/RandomDiceRoller.cs +++ b/RpgRoller/Services/RandomDiceRoller.cs @@ -4,6 +4,6 @@ public sealed class RandomDiceRoller : IDiceRoller { public int Roll(int sides) { - return Random.Shared.Next(1, sides + 1); + return System.Security.Cryptography.RandomNumberGenerator.GetInt32(1, sides + 1); } } diff --git a/RpgRoller/Services/ServiceResult.cs b/RpgRoller/Services/ServiceResult.cs new file mode 100644 index 0000000..9931552 --- /dev/null +++ b/RpgRoller/Services/ServiceResult.cs @@ -0,0 +1,30 @@ +namespace RpgRoller.Services; + +public sealed record ServiceError(string Code, string Message); + +public sealed class ServiceResult +{ + private ServiceResult(T value) + { + Value = value; + } + + private ServiceResult(ServiceError error) + { + Error = error; + } + + public T? Value { get; } + public ServiceError? Error { get; } + public bool Succeeded => Error is null; + + public static ServiceResult Success(T value) + { + return new ServiceResult(value); + } + + public static ServiceResult Failure(string code, string message) + { + return new ServiceResult(new ServiceError(code, message)); + } +} diff --git a/TECH.md b/TECH.md index e87372c..a3337df 100644 --- a/TECH.md +++ b/TECH.md @@ -10,6 +10,7 @@ - Generated client source: `RpgRoller/frontend/generated/api-client.ts` - Generated client output: `RpgRoller/wwwroot/generated/api-client.js` - Local CI parity entrypoint: `scripts/ci-local.ps1` +- Current backend features: auth/session, campaign/character/skill management, ruleset-aware rolls, filtered campaign logs, and SSE state updates. ## 1) Stack and baseline choices