Implement core backend domain and API workflows

This commit is contained in:
2026-02-24 22:13:34 +01:00
parent cd87d7378d
commit e54b9d2ce8
13 changed files with 1769 additions and 63 deletions

View File

@@ -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);

View 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);

View File

@@ -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;

View File

@@ -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();
}

View 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;
}
}
}

View 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);
}

View File

@@ -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);
}
}

View 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));
}
}