Refactor backend to remove service command models

This commit is contained in:
2026-02-25 11:11:35 +01:00
parent 50f56fdab7
commit 80938e8f25
16 changed files with 146 additions and 203 deletions

4
FAQ.md
View File

@@ -32,6 +32,6 @@ No. The backend loads state from SQLite once during startup into in-memory state
Coverage now includes the entire backend project (`RpgRoller`), including API/hosting/bootstrap code and services. It is no longer restricted to `RpgRoller.Services.*`.
## Why do backend services use `*Command` types instead of API request DTOs?
## Why do backend services avoid API request DTO dependencies?
Service workflows now consume service-layer command models (for example, `CreateCampaignCommand`) so endpoint transport contracts stay isolated in the API layer. This reduces coupling and keeps service code reusable when input shapes evolve at the HTTP boundary.
Service workflows accept explicit parameters (for example, `CreateCampaign(sessionToken, name, rulesetId)`) instead of API request DTOs. This keeps the service layer independent from HTTP transport contracts while avoiding extra service-only wrapper command types.

View File

@@ -19,8 +19,8 @@ Backend:
- `RpgRoller/Program.cs`: thin app bootstrap only
- `RpgRoller/Hosting/`: service registration + startup initialization
- `RpgRoller/Api/`: endpoint mapping modules, API-to-service request mapping, auth/session filter helpers
- `RpgRoller/Services/`: game workflows and service-layer command models (`*Command` records)
- `RpgRoller/Api/`: endpoint mapping modules and auth/session filter helpers
- `RpgRoller/Services/`: game workflows with explicit method parameters (no API DTO dependencies)
Frontend:

View File

@@ -8,11 +8,11 @@ public sealed class ServiceAuthTests
using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service;
var invalidUsername = service.Register(new RegisterCommand("", "Password123", "Display"));
var invalidDisplay = service.Register(new RegisterCommand("user", "Password123", ""));
var invalidPassword = service.Register(new RegisterCommand("user", "short", "Display"));
var valid = service.Register(new RegisterCommand("user", "Password123", "Display"));
var duplicate = service.Register(new RegisterCommand("user", "Password123", "Display 2"));
var invalidUsername = service.Register("", "Password123", "Display");
var invalidDisplay = service.Register("user", "Password123", "");
var invalidPassword = service.Register("user", "short", "Display");
var valid = service.Register("user", "Password123", "Display");
var duplicate = service.Register("user", "Password123", "Display 2");
Assert.False(invalidUsername.Succeeded);
Assert.False(invalidDisplay.Succeeded);
@@ -26,11 +26,11 @@ public sealed class ServiceAuthTests
{
using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service;
service.Register(new RegisterCommand("user", "Password123", "Display"));
service.Register("user", "Password123", "Display");
var invalidUser = service.Login(new LoginCommand("missing", "Password123"));
var invalidPassword = service.Login(new LoginCommand("user", "bad-password"));
var valid = service.Login(new LoginCommand("user", "Password123"));
var invalidUser = service.Login("missing", "Password123");
var invalidPassword = service.Login("user", "bad-password");
var valid = service.Login("user", "Password123");
Assert.False(invalidUser.Succeeded);
Assert.False(invalidPassword.Succeeded);
@@ -50,8 +50,8 @@ public sealed class ServiceAuthTests
using var harness = ServiceTestSupport.CreateHarness(hasher);
var service = harness.Service;
service.Register(new RegisterCommand("user", "Password123", "Display"));
var login = service.Login(new LoginCommand("user", "Password123"));
service.Register("user", "Password123", "Display");
var login = service.Login("user", "Password123");
Assert.True(login.Succeeded);
Assert.Equal(2, hasher.HashCalls);

View File

@@ -8,20 +8,20 @@ public sealed class ServiceCampaignTests
using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service;
var unauthorizedCampaign = service.CreateCampaign("missing", new CreateCampaignCommand("Name", "d6"));
var unauthorizedCampaign = service.CreateCampaign("missing", "Name", "d6");
Assert.False(unauthorizedCampaign.Succeeded);
service.Register(new RegisterCommand("gm", "Password123", "GM"));
var gmSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Name", "d6")));
service.Register("gm", "Password123", "GM");
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Name", "d6"));
var invalidRuleset = service.CreateCampaign(gmSession, new CreateCampaignCommand("Name 2", "unknown"));
var invalidRuleset = service.CreateCampaign(gmSession, "Name 2", "unknown");
Assert.False(invalidRuleset.Succeeded);
var noCampaignCharacter = service.CreateCharacter(gmSession, new CreateCharacterCommand("Hero", Guid.NewGuid()));
var noCampaignCharacter = service.CreateCharacter(gmSession, "Hero", Guid.NewGuid());
Assert.False(noCampaignCharacter.Succeeded);
var character = ServiceTestSupport.GetValue(service.CreateCharacter(gmSession, new CreateCharacterCommand("Hero", campaign.Id)));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(gmSession, "Hero", campaign.Id));
var missingCharacterActivate = service.ActivateCharacter(gmSession, Guid.NewGuid());
Assert.False(missingCharacterActivate.Succeeded);
@@ -42,8 +42,8 @@ public sealed class ServiceCampaignTests
{
using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service;
service.Register(new RegisterCommand("user", "Password123", "User"));
var sessionToken = ServiceTestSupport.GetValue(service.Login(new LoginCommand("user", "Password123"))).SessionToken;
service.Register("user", "Password123", "User");
var sessionToken = ServiceTestSupport.GetValue(service.Login("user", "Password123")).SessionToken;
var result = service.GetCurrentCampaignCharacters(sessionToken);
Assert.False(result.Succeeded);
@@ -54,14 +54,14 @@ public sealed class ServiceCampaignTests
{
using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service;
service.Register(new RegisterCommand("gm", "Password123", "GM"));
service.Register(new RegisterCommand("player", "Password123", "Player"));
var gmSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken;
var playerSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("player", "Password123"))).SessionToken;
service.Register("gm", "Password123", "GM");
service.Register("player", "Password123", "Player");
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
var playerSession = ServiceTestSupport.GetValue(service.Login("player", "Password123")).SessionToken;
var gmCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Owned", "d6")));
_ = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Owned 2", "d6")));
_ = service.CreateCharacter(playerSession, new CreateCharacterCommand("Joiner", gmCampaign.Id));
var gmCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Owned", "d6"));
_ = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Owned 2", "d6"));
_ = service.CreateCharacter(playerSession, "Joiner", gmCampaign.Id);
var playerCampaigns = service.GetCampaigns(playerSession);
Assert.True(playerCampaigns.Succeeded);
@@ -76,20 +76,20 @@ public sealed class ServiceCampaignTests
using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service;
service.Register(new RegisterCommand("gm", "Password123", "GM"));
service.Register(new RegisterCommand("owner", "Password123", "Owner"));
service.Register(new RegisterCommand("other", "Password123", "Other"));
service.Register("gm", "Password123", "GM");
service.Register("owner", "Password123", "Owner");
service.Register("other", "Password123", "Other");
var gmSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken;
var ownerSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("owner", "Password123"))).SessionToken;
var otherSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("other", "Password123"))).SessionToken;
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner", "Password123")).SessionToken;
var otherSession = ServiceTestSupport.GetValue(service.Login("other", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Main", "d6")));
var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, new CreateCharacterCommand("Owner Character", campaign.Id)));
var otherCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(otherSession, new CreateCharacterCommand("Other Character", campaign.Id)));
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Main", "d6"));
var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Character", campaign.Id));
var otherCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(otherSession, "Other Character", campaign.Id));
var ownerSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1")));
_ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, new CreateSkillCommand("Perception", "1D+2")));
var ownerSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1"));
_ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, "Perception", "1D+2"));
var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
Assert.Single(ownerView.Characters);

View File

@@ -8,35 +8,35 @@ public sealed class ServicePersistenceTests
using var harness = ServiceTestSupport.CreateHarness(2, 3, 4);
var service = harness.Service;
var invalidCredentials = service.Login(new LoginCommand("", ""));
var invalidCredentials = service.Login("", "");
Assert.False(invalidCredentials.Succeeded);
service.Logout("missing-session");
service.Register(new RegisterCommand("gm", "Password123", "GM"));
service.Register(new RegisterCommand("owner", "Password123", "Owner"));
service.Register(new RegisterCommand("other", "Password123", "Other"));
service.Register("gm", "Password123", "GM");
service.Register("owner", "Password123", "Owner");
service.Register("other", "Password123", "Other");
var gmSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken;
var ownerSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("owner", "Password123"))).SessionToken;
var otherSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("other", "Password123"))).SessionToken;
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner", "Password123")).SessionToken;
var otherSession = ServiceTestSupport.GetValue(service.Login("other", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Main", "d6")));
var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, new CreateCharacterCommand("Owner Character", campaign.Id)));
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Main", "d6"));
var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Character", campaign.Id));
Assert.False(service.GetMe(string.Empty).Succeeded);
Assert.False(service.CreateCampaign(gmSession, new CreateCampaignCommand("", "d6")).Succeeded);
Assert.False(service.CreateCampaign(gmSession, "", "d6").Succeeded);
Assert.False(service.GetCampaigns(string.Empty).Succeeded);
Assert.False(service.CreateCharacter(ownerSession, new CreateCharacterCommand("", campaign.Id)).Succeeded);
Assert.False(service.CreateCharacter(string.Empty, new CreateCharacterCommand("Name", campaign.Id)).Succeeded);
Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, new UpdateCharacterCommand("Renamed", campaign.Id)).Succeeded);
Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), new UpdateCharacterCommand("Renamed", campaign.Id)).Succeeded);
Assert.False(service.CreateCharacter(ownerSession, "", campaign.Id).Succeeded);
Assert.False(service.CreateCharacter(string.Empty, "Name", campaign.Id).Succeeded);
Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, "Renamed", campaign.Id).Succeeded);
Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), "Renamed", campaign.Id).Succeeded);
Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded);
Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded);
Assert.False(service.GetCurrentCampaignCharacters(string.Empty).Succeeded);
Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1")).Succeeded);
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), new CreateSkillCommand("Stealth", "2D+1")).Succeeded);
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1")).Succeeded);
Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, "Stealth", "2D+1").Succeeded);
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1").Succeeded);
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1").Succeeded);
using (var db = harness.CreateDbContext())
{
@@ -74,11 +74,11 @@ public sealed class ServicePersistenceTests
Assert.Null(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId);
}
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1")));
Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillCommand("", "2D+1")).Succeeded);
Assert.False(service.UpdateSkill(string.Empty, skill.Id, new UpdateSkillCommand("Stealth", "2D+1")).Succeeded);
Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillCommand("Stealth", "bad")).Succeeded);
Assert.False(service.RollSkill(string.Empty, skill.Id, new RollSkillCommand("public")).Succeeded);
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1"));
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "", "2D+1").Succeeded);
Assert.False(service.UpdateSkill(string.Empty, skill.Id, "Stealth", "2D+1").Succeeded);
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Stealth", "bad").Succeeded);
Assert.False(service.RollSkill(string.Empty, skill.Id, "public").Succeeded);
using (var db = harness.CreateDbContext())
{
@@ -88,7 +88,7 @@ public sealed class ServicePersistenceTests
}
using var invalidExpressionHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath, 2, 3, 4);
Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("public")).Succeeded);
Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, "public").Succeeded);
Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded);
}
}

View File

@@ -7,54 +7,54 @@ public sealed class ServiceSkillRollTests
{
using var harness = ServiceTestSupport.CreateHarness(3, 4, 5, 6);
var service = harness.Service;
service.Register(new RegisterCommand("gm", "Password123", "GM"));
service.Register(new RegisterCommand("owner", "Password123", "Owner"));
service.Register(new RegisterCommand("other", "Password123", "Other"));
service.Register("gm", "Password123", "GM");
service.Register("owner", "Password123", "Owner");
service.Register("other", "Password123", "Other");
var gmSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken;
var ownerSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("owner", "Password123"))).SessionToken;
var otherSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("other", "Password123"))).SessionToken;
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner", "Password123")).SessionToken;
var otherSession = ServiceTestSupport.GetValue(service.Login("other", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Main", "dnd5e")));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, new CreateCharacterCommand("Owner Char", campaign.Id)));
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Main", "dnd5e"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Char", campaign.Id));
var noPermissionUpdate = service.UpdateCharacter(otherSession, character.Id, new UpdateCharacterCommand("Renamed", campaign.Id));
var noPermissionUpdate = service.UpdateCharacter(otherSession, character.Id, "Renamed", campaign.Id);
Assert.False(noPermissionUpdate.Succeeded);
var invalidCharacterName = service.UpdateCharacter(ownerSession, character.Id, new UpdateCharacterCommand("", campaign.Id));
var invalidCharacterName = service.UpdateCharacter(ownerSession, character.Id, "", campaign.Id);
Assert.False(invalidCharacterName.Succeeded);
var missingTargetCampaign = service.UpdateCharacter(ownerSession, character.Id, new UpdateCharacterCommand("Renamed", Guid.NewGuid()));
var missingTargetCampaign = service.UpdateCharacter(ownerSession, character.Id, "Renamed", Guid.NewGuid());
Assert.False(missingTargetCampaign.Succeeded);
var noSkillName = service.CreateSkill(ownerSession, character.Id, new CreateSkillCommand("", "1d20"));
var noSkillName = service.CreateSkill(ownerSession, character.Id, "", "1d20");
Assert.False(noSkillName.Succeeded);
var invalidExpression = service.CreateSkill(ownerSession, character.Id, new CreateSkillCommand("Skill", "5D+4"));
var invalidExpression = service.CreateSkill(ownerSession, character.Id, "Skill", "5D+4");
Assert.False(invalidExpression.Succeeded);
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, new CreateSkillCommand("Skill", "1d20+2")));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Skill", "1d20+2"));
var missingSkillUpdate = service.UpdateSkill(ownerSession, Guid.NewGuid(), new UpdateSkillCommand("X", "1d20"));
var missingSkillUpdate = service.UpdateSkill(ownerSession, Guid.NewGuid(), "X", "1d20");
Assert.False(missingSkillUpdate.Succeeded);
var forbiddenSkillUpdate = service.UpdateSkill(otherSession, skill.Id, new UpdateSkillCommand("X", "1d20"));
var forbiddenSkillUpdate = service.UpdateSkill(otherSession, skill.Id, "X", "1d20");
Assert.False(forbiddenSkillUpdate.Succeeded);
var gmSkillUpdate = service.UpdateSkill(gmSession, skill.Id, new UpdateSkillCommand("GM Edit", "2d6+1"));
var gmSkillUpdate = service.UpdateSkill(gmSession, skill.Id, "GM Edit", "2d6+1");
Assert.True(gmSkillUpdate.Succeeded);
var missingRoll = service.RollSkill(ownerSession, Guid.NewGuid(), new RollSkillCommand("public"));
var missingRoll = service.RollSkill(ownerSession, Guid.NewGuid(), "public");
Assert.False(missingRoll.Succeeded);
var invalidVisibility = service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("hidden"));
var invalidVisibility = service.RollSkill(ownerSession, skill.Id, "hidden");
Assert.False(invalidVisibility.Succeeded);
var forbiddenRoll = service.RollSkill(otherSession, skill.Id, new RollSkillCommand("public"));
var forbiddenRoll = service.RollSkill(otherSession, skill.Id, "public");
Assert.False(forbiddenRoll.Succeeded);
var privateRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("private"));
var publicRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("public"));
var privateRoll = service.RollSkill(ownerSession, skill.Id, "private");
var publicRoll = service.RollSkill(ownerSession, skill.Id, "public");
Assert.True(privateRoll.Succeeded);
Assert.True(publicRoll.Succeeded);

View File

@@ -1,3 +0,0 @@
namespace RpgRoller.Tests;
// Integration API tests were split by concern under RpgRoller.Tests/Api.

View File

@@ -10,7 +10,7 @@ internal static class AuthEndpoints
{
group.MapPost("/auth/register", Results<Ok<UserSummary>, BadRequest<ApiError>> (RegisterRequest request, IGameService game) =>
{
var result = game.Register(request.ToCommand());
var result = game.Register(request.Username, request.Password, request.DisplayName);
if (!result.Succeeded)
{
return ApiResultMapper.ToBadRequest(result.Error!);
@@ -21,7 +21,7 @@ internal static class AuthEndpoints
group.MapPost("/auth/login", Results<Ok<UserSummary>, BadRequest<ApiError>> (LoginRequest request, HttpContext context, IGameService game) =>
{
var result = game.Login(request.ToCommand());
var result = game.Login(request.Username, request.Password);
if (!result.Succeeded)
{
return ApiResultMapper.ToBadRequest(result.Error!);

View File

@@ -9,7 +9,7 @@ internal static class CampaignEndpoints
{
group.MapPost("/campaigns", (CreateCampaignRequest request, HttpContext context, IGameService game) =>
{
var result = game.CreateCampaign(context.GetRequiredSessionToken(), request.ToCommand());
var result = game.CreateCampaign(context.GetRequiredSessionToken(), request.Name, request.RulesetId);
return ApiResultMapper.ToApiResult(result);
});

View File

@@ -9,13 +9,13 @@ internal static class CharacterEndpoints
{
group.MapPost("/characters", (CreateCharacterRequest request, HttpContext context, IGameService game) =>
{
var result = game.CreateCharacter(context.GetRequiredSessionToken(), request.ToCommand());
var result = game.CreateCharacter(context.GetRequiredSessionToken(), request.Name, request.CampaignId);
return ApiResultMapper.ToApiResult(result);
});
group.MapPut("/characters/{characterId:guid}", (Guid characterId, UpdateCharacterRequest request, HttpContext context, IGameService game) =>
{
var result = game.UpdateCharacter(context.GetRequiredSessionToken(), characterId, request.ToCommand());
var result = game.UpdateCharacter(context.GetRequiredSessionToken(), characterId, request.Name, request.CampaignId);
return ApiResultMapper.ToApiResult(result);
});

View File

@@ -1,47 +1,5 @@
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class RequestMappings
{
public static RegisterCommand ToCommand(this RegisterRequest request)
{
return new RegisterCommand(request.Username, request.Password, request.DisplayName);
}
public static LoginCommand ToCommand(this LoginRequest request)
{
return new LoginCommand(request.Username, request.Password);
}
public static CreateCampaignCommand ToCommand(this CreateCampaignRequest request)
{
return new CreateCampaignCommand(request.Name, request.RulesetId);
}
public static CreateCharacterCommand ToCommand(this CreateCharacterRequest request)
{
return new CreateCharacterCommand(request.Name, request.CampaignId);
}
public static UpdateCharacterCommand ToCommand(this UpdateCharacterRequest request)
{
return new UpdateCharacterCommand(request.Name, request.CampaignId);
}
public static CreateSkillCommand ToCommand(this CreateSkillRequest request)
{
return new CreateSkillCommand(request.Name, request.DiceRollDefinition);
}
public static UpdateSkillCommand ToCommand(this UpdateSkillRequest request)
{
return new UpdateSkillCommand(request.Name, request.DiceRollDefinition);
}
public static RollSkillCommand ToCommand(this RollSkillRequest request)
{
return new RollSkillCommand(request.Visibility);
}
}

View File

@@ -9,19 +9,19 @@ internal static class SkillEndpoints
{
group.MapPost("/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) =>
{
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.ToCommand());
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition);
return ApiResultMapper.ToApiResult(result);
});
group.MapPut("/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) =>
{
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.ToCommand());
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition);
return ApiResultMapper.ToApiResult(result);
});
group.MapPost("/skills/{skillId:guid}/roll", (Guid skillId, RollSkillRequest request, HttpContext context, IGameService game) =>
{
var result = game.RollSkill(context.GetRequiredSessionToken(), skillId, request.ToCommand());
var result = game.RollSkill(context.GetRequiredSessionToken(), skillId, request.Visibility);
return ApiResultMapper.ToApiResult(result);
});

View File

@@ -35,27 +35,27 @@ public sealed class GameService : IGameService
.ToArray();
}
public ServiceResult<UserSummary> Register(RegisterCommand request)
public ServiceResult<UserSummary> Register(string username, string password, string displayName)
{
if (string.IsNullOrWhiteSpace(request.Username))
if (string.IsNullOrWhiteSpace(username))
{
return ServiceResult<UserSummary>.Failure("invalid_username", "Username is required.");
}
if (string.IsNullOrWhiteSpace(request.DisplayName))
if (string.IsNullOrWhiteSpace(displayName))
{
return ServiceResult<UserSummary>.Failure("invalid_display_name", "Display name is required.");
}
if (string.IsNullOrWhiteSpace(request.Password) || request.Password.Length < 8)
if (string.IsNullOrWhiteSpace(password) || password.Length < 8)
{
return ServiceResult<UserSummary>.Failure("invalid_password", "Password must be at least 8 characters.");
}
lock (m_Gate)
{
var username = request.Username.Trim();
var normalizedUsername = NormalizeUsername(username);
var trimmedUsername = username.Trim();
var normalizedUsername = NormalizeUsername(trimmedUsername);
if (m_UserIdsByUsername.ContainsKey(normalizedUsername))
{
return ServiceResult<UserSummary>.Failure("duplicate_username", "Username is already taken.");
@@ -64,14 +64,14 @@ public sealed class GameService : IGameService
var user = new UserAccount
{
Id = Guid.NewGuid(),
Username = username,
Username = trimmedUsername,
UsernameNormalized = normalizedUsername,
DisplayName = request.DisplayName.Trim(),
DisplayName = displayName.Trim(),
PasswordHash = string.Empty,
ActiveCharacterId = null
};
user.PasswordHash = m_PasswordHasher.HashPassword(user, request.Password);
user.PasswordHash = m_PasswordHasher.HashPassword(user, password);
m_UsersById[user.Id] = user;
m_UserIdsByUsername[user.UsernameNormalized] = user.Id;
@@ -81,23 +81,23 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<(UserSummary User, string SessionToken)> Login(LoginCommand request)
public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
}
lock (m_Gate)
{
var normalizedUsername = NormalizeUsername(request.Username.Trim());
var normalizedUsername = NormalizeUsername(username.Trim());
if (!m_UserIdsByUsername.TryGetValue(normalizedUsername, 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);
var verification = m_PasswordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (verification == PasswordVerificationResult.Failed)
{
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
@@ -105,7 +105,7 @@ public sealed class GameService : IGameService
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
{
user.PasswordHash = m_PasswordHasher.HashPassword(user, request.Password);
user.PasswordHash = m_PasswordHasher.HashPassword(user, password);
}
var session = CreateSession(userId);
@@ -162,14 +162,14 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, CreateCampaignCommand request)
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
{
if (string.IsNullOrWhiteSpace(request.Name))
if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<CampaignSummary>.Failure("invalid_campaign_name", "Campaign name is required.");
}
var ruleset = DiceRules.TryParseRulesetId(request.RulesetId);
var ruleset = DiceRules.TryParseRulesetId(rulesetId);
if (ruleset is null)
{
return ServiceResult<CampaignSummary>.Failure("invalid_ruleset", "Unknown ruleset.");
@@ -187,7 +187,7 @@ public sealed class GameService : IGameService
{
Id = Guid.NewGuid(),
GmUserId = user.Id,
Name = request.Name.Trim(),
Name = name.Trim(),
Ruleset = ruleset.Value,
Version = 1
};
@@ -263,9 +263,9 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, CreateCharacterCommand request)
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId)
{
if (string.IsNullOrWhiteSpace(request.Name))
if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
}
@@ -278,7 +278,7 @@ public sealed class GameService : IGameService
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
}
if (!m_CampaignsById.ContainsKey(request.CampaignId))
if (!m_CampaignsById.ContainsKey(campaignId))
{
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
}
@@ -287,8 +287,8 @@ public sealed class GameService : IGameService
{
Id = Guid.NewGuid(),
OwnerUserId = user.Id,
CampaignId = request.CampaignId,
Name = request.Name.Trim()
CampaignId = campaignId,
Name = name.Trim()
};
m_CharactersById[character.Id] = character;
@@ -299,9 +299,9 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterCommand request)
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId)
{
if (string.IsNullOrWhiteSpace(request.Name))
if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
}
@@ -319,7 +319,7 @@ public sealed class GameService : IGameService
return ServiceResult<CharacterSummary>.Failure("character_not_found", "Character was not found.");
}
if (!m_CampaignsById.TryGetValue(request.CampaignId, out var targetCampaign))
if (!m_CampaignsById.TryGetValue(campaignId, out var targetCampaign))
{
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
}
@@ -334,8 +334,8 @@ public sealed class GameService : IGameService
}
var sourceCampaignId = character.CampaignId;
character.Name = request.Name.Trim();
character.CampaignId = request.CampaignId;
character.Name = name.Trim();
character.CampaignId = campaignId;
TouchCampaignLocked(sourceCampaignId);
if (sourceCampaignId != character.CampaignId)
@@ -399,9 +399,9 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, CreateSkillCommand request)
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition)
{
if (string.IsNullOrWhiteSpace(request.Name))
if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
}
@@ -425,7 +425,7 @@ public sealed class GameService : IGameService
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
}
var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, request.DiceRollDefinition);
var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, diceRollDefinition);
if (!expressionValidation.Succeeded)
{
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
@@ -435,7 +435,7 @@ public sealed class GameService : IGameService
{
Id = Guid.NewGuid(),
CharacterId = character.Id,
Name = request.Name.Trim(),
Name = name.Trim(),
DiceRollDefinition = expressionValidation.Value!.Canonical
};
@@ -447,9 +447,9 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, UpdateSkillCommand request)
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition)
{
if (string.IsNullOrWhiteSpace(request.Name))
if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
}
@@ -474,13 +474,13 @@ public sealed class GameService : IGameService
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
}
var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, request.DiceRollDefinition);
var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, diceRollDefinition);
if (!expressionValidation.Succeeded)
{
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
}
skill.Name = request.Name.Trim();
skill.Name = name.Trim();
skill.DiceRollDefinition = expressionValidation.Value!.Canonical;
TouchCampaignLocked(campaign.Id);
@@ -489,7 +489,7 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, RollSkillCommand request)
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility)
{
lock (m_Gate)
{
@@ -517,10 +517,10 @@ public sealed class GameService : IGameService
return ServiceResult<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message);
}
var visibility = ParseVisibility(request.Visibility);
if (!visibility.Succeeded)
var parsedVisibility = ParseVisibility(visibility);
if (!parsedVisibility.Succeeded)
{
return ServiceResult<RollResult>.Failure(visibility.Error!.Code, visibility.Error.Message);
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message);
}
var roll = ComputeRoll(parsedExpression.Value!);
@@ -531,7 +531,7 @@ public sealed class GameService : IGameService
CharacterId = character.Id,
SkillId = skill.Id,
RollerUserId = user.Id,
Visibility = visibility.Value,
Visibility = parsedVisibility.Value,
Result = roll.Total,
Breakdown = roll.Breakdown,
TimestampUtc = DateTimeOffset.UtcNow

View File

@@ -7,25 +7,25 @@ public interface IGameService
{
IReadOnlyList<RulesetDefinition> GetRulesets();
ServiceResult<UserSummary> Register(RegisterCommand request);
ServiceResult<(UserSummary User, string SessionToken)> Login(LoginCommand request);
ServiceResult<UserSummary> Register(string username, string password, string displayName);
ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password);
void Logout(string sessionToken);
UserSummary? GetUserBySession(string sessionToken);
ServiceResult<MeResponse> GetMe(string sessionToken);
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, CreateCampaignCommand request);
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId);
ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken);
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, CreateCharacterCommand request);
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterCommand request);
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId);
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId);
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken);
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, CreateSkillCommand request);
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, UpdateSkillCommand request);
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition);
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition);
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, RollSkillCommand request);
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
ServiceResult<long> GetCampaignVersion(string sessionToken, Guid campaignId);

View File

@@ -1,13 +1 @@
namespace RpgRoller.Services;
public sealed record RegisterCommand(string Username, string Password, string DisplayName);
public sealed record LoginCommand(string Username, string Password);
public sealed record CreateCampaignCommand(string Name, string RulesetId);
public sealed record CreateCharacterCommand(string Name, Guid CampaignId);
public sealed record UpdateCharacterCommand(string Name, Guid CampaignId);
public sealed record CreateSkillCommand(string Name, string DiceRollDefinition);
public sealed record UpdateSkillCommand(string Name, string DiceRollDefinition);
public sealed record RollSkillCommand(string Visibility);
// Intentionally left blank after removing service command records.

View File

@@ -14,7 +14,7 @@
- Generated client output: `RpgRoller/wwwroot/generated/api-client.js`
- Local CI parity entrypoint: `scripts/ci-local.ps1`
- API endpoint modules: `RpgRoller/Api/*Endpoints.cs` + shared session/auth helpers
- Service boundary model: API request DTOs are mapped to `RpgRoller.Services/*Command` records before workflow execution
- Service boundary model: API request DTOs are mapped to explicit service method parameters before workflow execution
- Current backend features: auth/session, campaign/character/skill management, ruleset-aware rolls, filtered campaign logs, and SSE state updates.
- Current frontend features: authenticated campaign workspace with live log updates and full roll workflow controls.