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

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.