Implement core backend domain and API workflows
This commit is contained in:
10
README.md
10
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
|
||||
|
||||
247
RpgRoller.Tests/GameServiceTests.cs
Normal file
247
RpgRoller.Tests/GameServiceTests.cs
Normal file
@@ -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<ArgumentOutOfRangeException>(() => 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<UserAccount>(), new FixedDiceRoller(rollValues));
|
||||
}
|
||||
|
||||
private static T GetValue<T>(ServiceResult<T> result)
|
||||
{
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.NotNull(result.Value);
|
||||
return result.Value!;
|
||||
}
|
||||
|
||||
private sealed class FixedDiceRoller : IDiceRoller
|
||||
{
|
||||
private readonly Queue<int> m_Values;
|
||||
|
||||
public FixedDiceRoller(IEnumerable<int> values)
|
||||
{
|
||||
m_Values = new Queue<int>(values);
|
||||
}
|
||||
|
||||
public int Roll(int sides)
|
||||
{
|
||||
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
|
||||
return Math.Clamp(next, 1, sides);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,80 +10,254 @@ namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class UnitTest1 : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly HttpClient m_Client;
|
||||
private readonly WebApplicationFactory<Program> m_BaseFactory;
|
||||
|
||||
public UnitTest1(WebApplicationFactory<Program> 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<MeResponse>(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<CreateCampaignRequest, CampaignSummary>(
|
||||
gmClient,
|
||||
"/api/campaigns",
|
||||
new CreateCampaignRequest("Alpha Campaign", "dnd5e"));
|
||||
|
||||
var gmCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(
|
||||
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<CreateSkillRequest, SkillSummary>(
|
||||
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<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
|
||||
Assert.Equal(campaign.Id, details.Id);
|
||||
Assert.Single(details.Characters);
|
||||
Assert.Single(details.Skills);
|
||||
|
||||
var currentCampaignCharacters = await GetAsync<IReadOnlyList<CharacterSummary>>(gmClient, "/api/characters/current-campaign");
|
||||
Assert.Single(currentCampaignCharacters);
|
||||
Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id);
|
||||
|
||||
var otherCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(
|
||||
gmClient,
|
||||
"/api/campaigns",
|
||||
new CreateCampaignRequest("Beta Campaign", "d6"));
|
||||
|
||||
var updatedCharacter = await PutAsync<UpdateCharacterRequest, CharacterSummary>(
|
||||
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<CreateCampaignRequest, CampaignSummary>(
|
||||
gmClient,
|
||||
"/api/campaigns",
|
||||
new CreateCampaignRequest("Main", "d6"));
|
||||
|
||||
await RegisterAsync(playerClient, "player", "Password123", "Player");
|
||||
await LoginAsync(playerClient, "player", "Password123");
|
||||
var playerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(
|
||||
playerClient,
|
||||
"/api/characters",
|
||||
new CreateCharacterRequest("Rogue", campaign.Id));
|
||||
|
||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(
|
||||
playerClient,
|
||||
$"/api/characters/{playerCharacter.Id}/skills",
|
||||
new CreateSkillRequest("Stealth", "2D+1"));
|
||||
|
||||
await RegisterAsync(observerClient, "observer", "Password123", "Observer");
|
||||
await LoginAsync(observerClient, "observer", "Password123");
|
||||
await PostAsync<CreateCharacterRequest, CharacterSummary>(
|
||||
observerClient,
|
||||
"/api/characters",
|
||||
new CreateCharacterRequest("Watcher", campaign.Id));
|
||||
|
||||
var privateRoll = await PostAsync<RollSkillRequest, RollResult>(
|
||||
playerClient,
|
||||
$"/api/skills/{skill.Id}/roll",
|
||||
new RollSkillRequest("private"));
|
||||
var publicRoll = await PostAsync<RollSkillRequest, RollResult>(
|
||||
playerClient,
|
||||
$"/api/skills/{skill.Id}/roll",
|
||||
new RollSkillRequest("public"));
|
||||
|
||||
Assert.Equal("private", privateRoll.Visibility);
|
||||
Assert.Equal("public", publicRoll.Visibility);
|
||||
|
||||
var gmLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(gmClient, $"/api/campaigns/{campaign.Id}/log");
|
||||
Assert.Equal(2, gmLog.Count);
|
||||
|
||||
var playerLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(playerClient, $"/api/campaigns/{campaign.Id}/log");
|
||||
Assert.Equal(2, playerLog.Count);
|
||||
|
||||
var observerLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(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<IReadOnlyList<RulesetDefinition>>(client, "/api/rulesets");
|
||||
Assert.Equal(2, rulesets.Count);
|
||||
|
||||
await RegisterAsync(client, "sse", "Password123", "Sse User");
|
||||
await LoginAsync(client, "sse", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(
|
||||
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<Program> CreateFactory(params int[] rollValues)
|
||||
{
|
||||
return m_BaseFactory.WithWebHostBuilder(builder =>
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IDiceRoller>();
|
||||
services.AddSingleton<IDiceRoller>(new FixedDiceRoller(7));
|
||||
})).CreateClient();
|
||||
services.AddSingleton<IDiceRoller>(new FixedDiceRoller(rollValues));
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHealth_ReturnsOkPayload()
|
||||
private static async Task<UserSummary> RegisterAsync(HttpClient client, string username, string password, string displayName)
|
||||
{
|
||||
var response = await m_Client.GetAsync("/api/health");
|
||||
var payload = await response.Content.ReadFromJsonAsync<HealthResponse>();
|
||||
return await PostAsync<RegisterRequest, UserSummary>(
|
||||
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<TResponse> PostAsync<TRequest, TResponse>(HttpClient client, string uri, TRequest payload)
|
||||
{
|
||||
var response = await m_Client.GetAsync($"/api/roll/{sides}");
|
||||
var payload = await response.Content.ReadFromJsonAsync<ApiError>();
|
||||
|
||||
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<RollResponse>();
|
||||
|
||||
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<TResponse>();
|
||||
Assert.NotNull(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RandomDiceRoller_ProducesValueWithinRange()
|
||||
private static async Task<TResponse> PutAsync<TRequest, TResponse>(HttpClient client, string uri, TRequest payload)
|
||||
{
|
||||
var roller = new RandomDiceRoller();
|
||||
|
||||
for (var i = 0; i < 200; i += 1)
|
||||
{
|
||||
var value = roller.Roll(6);
|
||||
Assert.InRange(value, 1, 6);
|
||||
var response = await client.PutAsJsonAsync(uri, payload);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<TResponse>();
|
||||
Assert.NotNull(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task<TResponse> GetAsync<TResponse>(HttpClient client, string uri)
|
||||
{
|
||||
var response = await client.GetAsync(uri);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<TResponse>();
|
||||
Assert.NotNull(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed class FixedDiceRoller : IDiceRoller
|
||||
{
|
||||
private readonly int m_Result;
|
||||
private readonly Queue<int> m_Values;
|
||||
|
||||
public FixedDiceRoller(int result)
|
||||
public FixedDiceRoller(IEnumerable<int> values)
|
||||
{
|
||||
m_Result = result;
|
||||
m_Values = new Queue<int>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<DataCollector friendlyName="XPlat code coverage">
|
||||
<Configuration>
|
||||
<Format>cobertura</Format>
|
||||
<Include>[RpgRoller]RpgRoller.Services.*</Include>
|
||||
<ExcludeByAttribute>GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
|
||||
</Configuration>
|
||||
</DataCollector>
|
||||
|
||||
@@ -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<CharacterSummary> Characters,
|
||||
IReadOnlyList<SkillSummary> 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);
|
||||
|
||||
68
RpgRoller/Domain/GameModels.cs
Normal file
68
RpgRoller/Domain/GameModels.cs
Normal file
@@ -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);
|
||||
@@ -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<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>();
|
||||
builder.Services.AddSingleton<IDiceRoller, RandomDiceRoller>();
|
||||
builder.Services.AddSingleton<IGameService, GameService>();
|
||||
|
||||
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<Ok<RollResponse>, BadRequest<ApiError>> (int sides, IDiceRoller diceRoller) =>
|
||||
app.MapPost("/api/auth/register", Results<Ok<UserSummary>, BadRequest<ApiError>> (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<Ok<UserSummary>, BadRequest<ApiError>> (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<Ok<MeResponse>, BadRequest<ApiError>, 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<IResult> (
|
||||
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<Ok<T>, BadRequest<ApiError>, UnauthorizedHttpResult> ToApiResult<T>(ServiceResult<T> 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<ApiError> ToBadRequest(ServiceError error)
|
||||
{
|
||||
return TypedResults.BadRequest(new ApiError(error.Message));
|
||||
}
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -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<DiceExpression> ParseExpression(RulesetKind ruleset, string expression)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
{
|
||||
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Dice expression is required.");
|
||||
}
|
||||
|
||||
var trimmed = expression.Trim();
|
||||
return ruleset switch
|
||||
{
|
||||
RulesetKind.D6 => ParseD6(trimmed),
|
||||
RulesetKind.Dnd5e => ParseDnd5e(trimmed),
|
||||
_ => ServiceResult<DiceExpression>.Failure("invalid_ruleset", "Unknown ruleset.")
|
||||
};
|
||||
}
|
||||
|
||||
private static ServiceResult<DiceExpression> ParseD6(string expression)
|
||||
{
|
||||
var match = D6Regex().Match(expression);
|
||||
if (!match.Success)
|
||||
{
|
||||
return ServiceResult<DiceExpression>.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<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
|
||||
}
|
||||
|
||||
return ServiceResult<DiceExpression>.Success(new DiceExpression(diceCount, 6, modifier, $"{diceCount}D{FormatModifier(modifier)}"));
|
||||
}
|
||||
|
||||
private static ServiceResult<DiceExpression> ParseDnd5e(string expression)
|
||||
{
|
||||
var match = Dnd5eRegex().Match(expression);
|
||||
if (!match.Success)
|
||||
{
|
||||
return ServiceResult<DiceExpression>.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<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
|
||||
}
|
||||
|
||||
return ServiceResult<DiceExpression>.Success(new DiceExpression(diceCount, sides, modifier, $"{diceCount}d{sides}{FormatModifier(modifier)}"));
|
||||
}
|
||||
|
||||
private static ServiceResult<bool> ValidateDiceParts(int diceCount, int sides, int modifier)
|
||||
{
|
||||
if (diceCount < 1 || diceCount > MaxDiceCount)
|
||||
{
|
||||
return ServiceResult<bool>.Failure("invalid_expression", $"Dice count must be between 1 and {MaxDiceCount}.");
|
||||
}
|
||||
|
||||
if (sides < 2 || sides > MaxSides)
|
||||
{
|
||||
return ServiceResult<bool>.Failure("invalid_expression", $"Dice sides must be between 2 and {MaxSides}.");
|
||||
}
|
||||
|
||||
if (modifier < 0 || modifier > MaxModifier)
|
||||
{
|
||||
return ServiceResult<bool>.Failure("invalid_expression", $"Modifier must be between 0 and {MaxModifier}.");
|
||||
}
|
||||
|
||||
return ServiceResult<bool>.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("^(?<count>\\d+)D(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex D6Regex();
|
||||
|
||||
[GeneratedRegex("^(?<count>\\d+)d(?<sides>\\d+)(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex Dnd5eRegex();
|
||||
}
|
||||
|
||||
722
RpgRoller/Services/GameService.cs
Normal file
722
RpgRoller/Services/GameService.cs
Normal file
@@ -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<UserAccount> m_PasswordHasher;
|
||||
private readonly IDiceRoller m_DiceRoller;
|
||||
private readonly Dictionary<Guid, UserAccount> m_UsersById = [];
|
||||
private readonly Dictionary<string, Guid> m_UserIdsByUsername = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, UserSession> m_SessionsByToken = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<Guid, Campaign> m_CampaignsById = [];
|
||||
private readonly Dictionary<Guid, Character> m_CharactersById = [];
|
||||
private readonly Dictionary<Guid, Skill> m_SkillsById = [];
|
||||
private readonly List<RollLogEntry> m_RollLog = [];
|
||||
private readonly Dictionary<Guid, Guid> m_ActiveCharacterByUserId = [];
|
||||
|
||||
public GameService(IPasswordHasher<UserAccount> passwordHasher, IDiceRoller diceRoller)
|
||||
{
|
||||
m_PasswordHasher = passwordHasher;
|
||||
m_DiceRoller = diceRoller;
|
||||
}
|
||||
|
||||
public IReadOnlyList<RulesetDefinition> GetRulesets()
|
||||
{
|
||||
return DiceRules.SupportedRulesets
|
||||
.Select(r => new RulesetDefinition(r.Id, r.Name, r.DiceSyntax))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public ServiceResult<UserSummary> Register(RegisterRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Username))
|
||||
{
|
||||
return ServiceResult<UserSummary>.Failure("invalid_username", "Username is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.DisplayName))
|
||||
{
|
||||
return ServiceResult<UserSummary>.Failure("invalid_display_name", "Display name is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Password) || request.Password.Length < 8)
|
||||
{
|
||||
return ServiceResult<UserSummary>.Failure("invalid_password", "Password must be at least 8 characters.");
|
||||
}
|
||||
|
||||
lock (m_Gate)
|
||||
{
|
||||
if (m_UserIdsByUsername.ContainsKey(request.Username))
|
||||
{
|
||||
return ServiceResult<UserSummary>.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<UserSummary>.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<MeResponse> GetMe(string sessionToken)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<MeResponse>.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<MeResponse>.Success(new MeResponse(ToUserSummary(user), activeCharacterId == Guid.Empty ? null : activeCharacterId, campaignId));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, CreateCampaignRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return ServiceResult<CampaignSummary>.Failure("invalid_campaign_name", "Campaign name is required.");
|
||||
}
|
||||
|
||||
var ruleset = DiceRules.TryParseRulesetId(request.RulesetId);
|
||||
if (ruleset is null)
|
||||
{
|
||||
return ServiceResult<CampaignSummary>.Failure("invalid_ruleset", "Unknown ruleset.");
|
||||
}
|
||||
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<CampaignSummary>.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<CampaignSummary>.Success(ToCampaignSummary(campaign));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unauthorized", "You must be logged in.");
|
||||
}
|
||||
|
||||
var campaignIds = new HashSet<Guid>(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<IReadOnlyList<CampaignSummary>>.Success(results);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var context = ResolveContextLocked(sessionToken, campaignId);
|
||||
if (!context.Succeeded)
|
||||
{
|
||||
return ServiceResult<CampaignDetails>.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<CampaignDetails>.Success(new CampaignDetails(
|
||||
campaign.Id,
|
||||
campaign.Name,
|
||||
DiceRules.ToRulesetId(campaign.Ruleset),
|
||||
ToUserSummary(gm),
|
||||
characters,
|
||||
skills));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, CreateCharacterRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
|
||||
}
|
||||
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
}
|
||||
|
||||
if (!m_CampaignsById.ContainsKey(request.CampaignId))
|
||||
{
|
||||
return ServiceResult<CharacterSummary>.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<CharacterSummary>.Success(ToCharacterSummary(character));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
|
||||
}
|
||||
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
}
|
||||
|
||||
if (!m_CharactersById.TryGetValue(characterId, out var character))
|
||||
{
|
||||
return ServiceResult<CharacterSummary>.Failure("character_not_found", "Character was not found.");
|
||||
}
|
||||
|
||||
if (!m_CampaignsById.TryGetValue(request.CampaignId, out var targetCampaign))
|
||||
{
|
||||
return ServiceResult<CharacterSummary>.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<CharacterSummary>.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<CharacterSummary>.Success(ToCharacterSummary(character));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
|
||||
}
|
||||
|
||||
if (!m_CharactersById.TryGetValue(characterId, out var character))
|
||||
{
|
||||
return ServiceResult<bool>.Failure("character_not_found", "Character was not found.");
|
||||
}
|
||||
|
||||
if (character.OwnerUserId != user.Id)
|
||||
{
|
||||
return ServiceResult<bool>.Failure("forbidden", "You can activate only your own character.");
|
||||
}
|
||||
|
||||
m_ActiveCharacterByUserId[user.Id] = character.Id;
|
||||
return ServiceResult<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in.");
|
||||
}
|
||||
|
||||
if (!TryGetCurrentCampaignIdLocked(user.Id, out var campaignId))
|
||||
{
|
||||
return ServiceResult<IReadOnlyList<CharacterSummary>>.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<IReadOnlyList<CharacterSummary>>.Success(characters);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, CreateSkillRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
|
||||
}
|
||||
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
}
|
||||
|
||||
if (!m_CharactersById.TryGetValue(characterId, out var character))
|
||||
{
|
||||
return ServiceResult<SkillSummary>.Failure("character_not_found", "Character was not found.");
|
||||
}
|
||||
|
||||
var campaign = m_CampaignsById[character.CampaignId];
|
||||
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
||||
{
|
||||
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
||||
}
|
||||
|
||||
var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, request.DiceRollDefinition);
|
||||
if (!expressionValidation.Succeeded)
|
||||
{
|
||||
return ServiceResult<SkillSummary>.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<SkillSummary>.Success(ToSkillSummary(skill));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, UpdateSkillRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
|
||||
}
|
||||
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
}
|
||||
|
||||
if (!m_SkillsById.TryGetValue(skillId, out var skill))
|
||||
{
|
||||
return ServiceResult<SkillSummary>.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<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
||||
}
|
||||
|
||||
var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, request.DiceRollDefinition);
|
||||
if (!expressionValidation.Succeeded)
|
||||
{
|
||||
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
|
||||
}
|
||||
|
||||
skill.Name = request.Name.Trim();
|
||||
skill.DiceRollDefinition = expressionValidation.Value!.Canonical;
|
||||
TouchCampaignLocked(campaign.Id);
|
||||
|
||||
return ServiceResult<SkillSummary>.Success(ToSkillSummary(skill));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, RollSkillRequest request)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in.");
|
||||
}
|
||||
|
||||
if (!m_SkillsById.TryGetValue(skillId, out var skill))
|
||||
{
|
||||
return ServiceResult<RollResult>.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<RollResult>.Failure("forbidden", "Only the owner or GM can roll this skill.");
|
||||
}
|
||||
|
||||
var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, skill.DiceRollDefinition);
|
||||
if (!parsedExpression.Succeeded)
|
||||
{
|
||||
return ServiceResult<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message);
|
||||
}
|
||||
|
||||
var visibility = ParseVisibility(request.Visibility);
|
||||
if (!visibility.Succeeded)
|
||||
{
|
||||
return ServiceResult<RollResult>.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<RollResult>.Success(ToRollResult(entry));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var context = ResolveContextLocked(sessionToken, campaignId);
|
||||
if (!context.Succeeded)
|
||||
{
|
||||
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.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<IReadOnlyList<CampaignLogEntry>>.Success(entries);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<long> GetCampaignVersion(string sessionToken, Guid campaignId)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var context = ResolveContextLocked(sessionToken, campaignId);
|
||||
if (!context.Succeeded)
|
||||
{
|
||||
return ServiceResult<long>.Failure(context.Error!.Code, context.Error.Message);
|
||||
}
|
||||
|
||||
return ServiceResult<long>.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<RollVisibility> ParseVisibility(string visibility)
|
||||
{
|
||||
if (string.Equals(visibility, "public", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ServiceResult<RollVisibility>.Success(RollVisibility.Public);
|
||||
}
|
||||
|
||||
if (string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ServiceResult<RollVisibility>.Success(RollVisibility.Private);
|
||||
}
|
||||
|
||||
return ServiceResult<RollVisibility>.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
RpgRoller/Services/IGameService.cs
Normal file
32
RpgRoller/Services/IGameService.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public interface IGameService
|
||||
{
|
||||
IReadOnlyList<RulesetDefinition> GetRulesets();
|
||||
|
||||
ServiceResult<UserSummary> Register(RegisterRequest request);
|
||||
ServiceResult<(UserSummary User, string SessionToken)> Login(LoginRequest request);
|
||||
void Logout(string sessionToken);
|
||||
UserSummary? GetUserBySession(string sessionToken);
|
||||
ServiceResult<MeResponse> GetMe(string sessionToken);
|
||||
|
||||
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, CreateCampaignRequest request);
|
||||
ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken);
|
||||
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
|
||||
|
||||
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, CreateCharacterRequest request);
|
||||
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterRequest request);
|
||||
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
|
||||
ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken);
|
||||
|
||||
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, CreateSkillRequest request);
|
||||
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, UpdateSkillRequest request);
|
||||
|
||||
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, RollSkillRequest request);
|
||||
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
|
||||
|
||||
ServiceResult<long> GetCampaignVersion(string sessionToken, Guid campaignId);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
30
RpgRoller/Services/ServiceResult.cs
Normal file
30
RpgRoller/Services/ServiceResult.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public sealed record ServiceError(string Code, string Message);
|
||||
|
||||
public sealed class ServiceResult<T>
|
||||
{
|
||||
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<T> Success(T value)
|
||||
{
|
||||
return new ServiceResult<T>(value);
|
||||
}
|
||||
|
||||
public static ServiceResult<T> Failure(string code, string message)
|
||||
{
|
||||
return new ServiceResult<T>(new ServiceError(code, message));
|
||||
}
|
||||
}
|
||||
1
TECH.md
1
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user