Files
RpgRoller/RpgRoller/Services/GameService.cs

1757 lines
74 KiB
C#

using System.Text.Json;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using RpgRoller.Contracts;
using RpgRoller.Data;
using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class GameService : IGameService
{
public GameService(IDbContextFactory<RpgRollerDbContext> dbContextFactory, IPasswordHasher<UserAccount> passwordHasher, IDiceRoller diceRoller)
{
m_DbContextFactory = dbContextFactory;
m_PasswordHasher = passwordHasher;
m_DiceRoller = diceRoller;
LoadStateFromDatabase();
}
public IReadOnlyList<RulesetDefinition> GetRulesets()
{
return DiceRules.SupportedRulesets.Select(r => new RulesetDefinition(r.Id, r.Name, r.DiceSyntax)).ToArray();
}
public ServiceResult<UserSummary> Register(string username, string password, string displayName)
{
if (string.IsNullOrWhiteSpace(username))
return ServiceResult<UserSummary>.Failure("invalid_username", "Username is required.");
if (string.IsNullOrWhiteSpace(displayName))
return ServiceResult<UserSummary>.Failure("invalid_display_name", "Display name is required.");
if (string.IsNullOrWhiteSpace(password) || password.Length < 8)
return ServiceResult<UserSummary>.Failure("invalid_password", "Password must be at least 8 characters.");
lock (m_Gate)
{
var trimmedUsername = username.Trim();
var normalizedUsername = NormalizeUsername(trimmedUsername);
if (m_UserIdsByUsername.ContainsKey(normalizedUsername))
return ServiceResult<UserSummary>.Failure("duplicate_username", "Username is already taken.");
var user = new UserAccount
{
Id = Guid.NewGuid(),
Username = trimmedUsername,
UsernameNormalized = normalizedUsername,
DisplayName = displayName.Trim(),
PasswordHash = string.Empty,
Roles = m_UsersById.Count == 0 ? UserRoles.Admin : string.Empty,
ActiveCharacterId = null
};
user.PasswordHash = m_PasswordHasher.HashPassword(user, password);
m_UsersById[user.Id] = user;
m_UserIdsByUsername[user.UsernameNormalized] = user.Id;
PersistStateLocked();
return ServiceResult<UserSummary>.Success(ToUserSummary(user));
}
}
public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string 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(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, 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, password);
var session = CreateSession(userId);
PersistStateLocked();
return ServiceResult<(UserSummary User, string SessionToken)>.Success((ToUserSummary(user), session.Token));
}
}
public void Logout(string sessionToken)
{
lock (m_Gate)
{
if (m_SessionsByToken.Remove(sessionToken))
PersistStateLocked();
}
}
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.");
Guid? campaignId = null;
if (user.ActiveCharacterId is Guid activeCharacterId)
{
if (!m_CharactersById.TryGetValue(activeCharacterId, out var activeCharacter))
{
user.ActiveCharacterId = null;
PersistStateLocked();
}
else
campaignId = activeCharacter.CampaignId;
}
return ServiceResult<MeResponse>.Success(new(ToUserSummary(user), user.ActiveCharacterId, campaignId));
}
}
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
{
if (string.IsNullOrWhiteSpace(name))
return ServiceResult<CampaignSummary>.Failure("invalid_campaign_name", "Campaign name is required.");
var ruleset = DiceRules.TryParseRulesetId(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 = name.Trim(),
Ruleset = ruleset.Value,
Version = 1
};
m_CampaignsById[campaign.Id] = campaign;
PersistStateLocked();
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.");
IEnumerable<Campaign> visibleCampaigns;
if (UserHasRoleLocked(user, UserRoles.Admin))
{
visibleCampaigns = m_CampaignsById.Values;
}
else
{
var campaignIds = new HashSet<Guid>(m_CampaignsById.Values.Where(campaign => campaign.GmUserId == user.Id).Select(campaign => campaign.Id));
foreach (var character in m_CharactersById.Values.Where(character => character.OwnerUserId == user.Id && character.CampaignId.HasValue))
campaignIds.Add(character.CampaignId!.Value);
visibleCampaigns = campaignIds.Select(campaignId => m_CampaignsById[campaignId]);
}
var results = visibleCampaigns.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).Select(ToCampaignSummary).ToArray();
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(results);
}
}
public ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<CampaignOption>>.Failure("unauthorized", "You must be logged in.");
var options = m_CampaignsById.Values
.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCampaignOption)
.ToArray();
return ServiceResult<IReadOnlyList<CampaignOption>>.Success(options);
}
}
public ServiceResult<CampaignRoster> GetCampaign(string sessionToken, Guid campaignId)
{
lock (m_Gate)
{
var context = ResolveContextLocked(sessionToken, campaignId);
if (!context.Succeeded)
return ServiceResult<CampaignRoster>.Failure(context.Error!.Code, context.Error.Message);
var (_, campaign) = context.Value;
return ServiceResult<CampaignRoster>.Success(ToCampaignRoster(campaign));
}
}
public ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
if (!m_CampaignsById.TryGetValue(campaignId, out var campaign))
return ServiceResult<bool>.Failure("campaign_not_found", "Campaign was not found.");
if (campaign.GmUserId != user.Id && !UserHasRoleLocked(user, UserRoles.Admin))
return ServiceResult<bool>.Failure("forbidden", "Only the campaign owner or admin can delete this campaign.");
DeleteCampaignLocked(campaignId);
PersistStateLocked();
return ServiceResult<bool>.Success(true);
}
}
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<string>>.Failure("unauthorized", "You must be logged in.");
var usernames = m_UsersById.Values.Select(account => account.Username).OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToArray();
return ServiceResult<IReadOnlyList<string>>.Success(usernames);
}
}
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("unauthorized", "You must be logged in.");
if (!UserHasRoleLocked(user, UserRoles.Admin))
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("forbidden", "Admin role is required.");
var users = m_UsersById.Values
.OrderBy(account => account.Username, StringComparer.OrdinalIgnoreCase)
.Select(ToAdminUserSummary)
.ToArray();
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Success(users);
}
}
public ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<AdminUserSummary>.Failure("unauthorized", "You must be logged in.");
if (!UserHasRoleLocked(user, UserRoles.Admin))
return ServiceResult<AdminUserSummary>.Failure("forbidden", "Admin role is required.");
if (!m_UsersById.TryGetValue(userId, out var targetUser))
return ServiceResult<AdminUserSummary>.Failure("user_not_found", "User was not found.");
var normalizedRoles = NormalizeRoles(roles);
if (normalizedRoles.Any(role => !string.Equals(role, UserRoles.Admin, StringComparison.Ordinal)))
return ServiceResult<AdminUserSummary>.Failure("invalid_role", "Unsupported role.");
if (user.Id == targetUser.Id && !normalizedRoles.Contains(UserRoles.Admin, StringComparer.Ordinal))
return ServiceResult<AdminUserSummary>.Failure("forbidden", "You cannot remove your own admin role.");
targetUser.Roles = SerializeRoles(normalizedRoles);
PersistStateLocked();
return ServiceResult<AdminUserSummary>.Success(ToAdminUserSummary(targetUser));
}
}
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
if (!UserHasRoleLocked(user, UserRoles.Admin))
return ServiceResult<bool>.Failure("forbidden", "Admin role is required.");
if (user.Id == userId)
return ServiceResult<bool>.Failure("forbidden", "You cannot delete your own account.");
if (!m_UsersById.TryGetValue(userId, out var targetUser))
return ServiceResult<bool>.Failure("user_not_found", "User was not found.");
var gmCampaignIds = m_CampaignsById.Values.Where(campaign => campaign.GmUserId == targetUser.Id).Select(campaign => campaign.Id).ToArray();
var gmCampaignIdSet = gmCampaignIds.ToHashSet();
var preservedCharacterIds = m_CharactersById.Values
.Where(character => character.CampaignId.HasValue && gmCampaignIdSet.Contains(character.CampaignId.Value))
.Select(character => character.Id)
.ToHashSet();
foreach (var campaignId in gmCampaignIds)
DeleteCampaignLocked(campaignId);
var ownedCharacterIds = m_CharactersById.Values
.Where(character => character.OwnerUserId == targetUser.Id && !preservedCharacterIds.Contains(character.Id))
.Select(character => character.Id)
.ToArray();
foreach (var characterId in ownedCharacterIds)
DeleteCharacterLocked(characterId);
m_RollLog.RemoveAll(entry => entry.RollerUserId == targetUser.Id);
var staleSessions = m_SessionsByToken.Values.Where(session => session.UserId == targetUser.Id).Select(session => session.Token).ToArray();
foreach (var token in staleSessions)
m_SessionsByToken.Remove(token);
m_UsersById.Remove(targetUser.Id);
m_UserIdsByUsername.Remove(targetUser.UsernameNormalized);
PersistStateLocked();
return ServiceResult<bool>.Success(true);
}
}
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId)
{
if (string.IsNullOrWhiteSpace(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(campaignId))
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
var character = new Character
{
Id = Guid.NewGuid(),
OwnerUserId = user.Id,
CampaignId = campaignId,
Name = name.Trim()
};
m_CharactersById[character.Id] = character;
AddCharacterStateLocked(character.CampaignId, character.Id);
TouchRosterLocked(character.CampaignId);
PersistStateLocked();
return ServiceResult<CharacterSummary>.Success(ToCharacterSummary(character));
}
}
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null)
{
if (string.IsNullOrWhiteSpace(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.");
Campaign? targetCampaign = null;
if (campaignId.HasValue && !m_CampaignsById.TryGetValue(campaignId.Value, out targetCampaign))
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
var isOwner = character.OwnerUserId == user.Id;
var isAdmin = UserHasRoleLocked(user, UserRoles.Admin);
var isSourceGm = character.CampaignId.HasValue &&
m_CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) &&
sourceCampaign.GmUserId == user.Id;
var isTargetGm = targetCampaign is not null && targetCampaign.GmUserId == user.Id;
if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm)
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner, GM, or admin can edit this character.");
var sourceCampaignId = character.CampaignId;
var previousOwnerUserId = character.OwnerUserId;
character.Name = name.Trim();
character.CampaignId = campaignId;
if (!string.IsNullOrWhiteSpace(ownerUsername))
{
var trimmedOwnerUsername = ownerUsername.Trim();
var normalizedOwnerUsername = NormalizeUsername(trimmedOwnerUsername);
if (!m_UserIdsByUsername.TryGetValue(normalizedOwnerUsername, out var targetOwnerUserId))
return ServiceResult<CharacterSummary>.Failure("owner_not_found", "Owner username was not found.");
if (targetOwnerUserId != character.OwnerUserId && !isAdmin && !isSourceGm && !isTargetGm)
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the GM or admin can change character owner.");
character.OwnerUserId = targetOwnerUserId;
if (character.OwnerUserId != previousOwnerUserId &&
m_UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) &&
previousOwner.ActiveCharacterId == character.Id)
{
previousOwner.ActiveCharacterId = null;
}
}
if (sourceCampaignId != character.CampaignId)
{
RemoveCharacterStateLocked(sourceCampaignId, character.Id);
AddCharacterStateLocked(character.CampaignId, character.Id);
}
TouchRosterLocked(sourceCampaignId);
if (sourceCampaignId != character.CampaignId)
TouchRosterLocked(character.CampaignId);
PersistStateLocked();
return ServiceResult<CharacterSummary>.Success(ToCharacterSummary(character));
}
}
public ServiceResult<bool> DeleteCharacter(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.");
var isOwner = character.OwnerUserId == user.Id;
var isAdmin = UserHasRoleLocked(user, UserRoles.Admin);
if (!isOwner && !isAdmin)
return ServiceResult<bool>.Failure("forbidden", "Only the owner or admin can delete this character.");
DeleteCharacterLocked(characterId);
PersistStateLocked();
return ServiceResult<bool>.Success(true);
}
}
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.");
user.ActiveCharacterId = character.Id;
PersistStateLocked();
return ServiceResult<bool>.Success(true);
}
}
public ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in.");
var characters = m_CharactersById.Values.Where(c => c.OwnerUserId == user.Id).OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).Select(ToCharacterSummary).ToArray();
return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters);
}
}
public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null)
{
if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required.");
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<SkillGroupSummary>.Failure("unauthorized", "You must be logged in.");
if (!m_CharactersById.TryGetValue(characterId, out var character))
return ServiceResult<SkillGroupSummary>.Failure("character_not_found", "Character was not found.");
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
return ServiceResult<SkillGroupSummary>.Failure(campaignError!.Code, campaignError.Message);
if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
var prototypeValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange);
if (!prototypeValidation.Succeeded)
return ServiceResult<SkillGroupSummary>.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message);
var group = new SkillGroup
{
Id = Guid.NewGuid(),
CharacterId = character.Id,
Name = name.Trim(),
DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression,
WildDice = prototypeValidation.Value.WildDice,
AllowFumble = prototypeValidation.Value.AllowFumble,
FumbleRange = prototypeValidation.Value.FumbleRange
};
m_SkillGroupsById[group.Id] = group;
TouchCharacterLocked(campaign.Id, character.Id);
PersistStateLocked();
return ServiceResult<SkillGroupSummary>.Success(ToSkillGroupSummary(group));
}
}
public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null)
{
if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required.");
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<SkillGroupSummary>.Failure("unauthorized", "You must be logged in.");
if (!m_SkillGroupsById.TryGetValue(skillGroupId, out var group))
return ServiceResult<SkillGroupSummary>.Failure("skill_group_not_found", "Skill group was not found.");
var character = m_CharactersById[group.CharacterId];
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
return ServiceResult<SkillGroupSummary>.Failure(campaignError!.Code, campaignError.Message);
if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
var prototypeValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange);
if (!prototypeValidation.Succeeded)
return ServiceResult<SkillGroupSummary>.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message);
group.Name = name.Trim();
group.DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression;
group.WildDice = prototypeValidation.Value.WildDice;
group.AllowFumble = prototypeValidation.Value.AllowFumble;
group.FumbleRange = prototypeValidation.Value.FumbleRange;
TouchCharacterLocked(campaign.Id, character.Id);
PersistStateLocked();
return ServiceResult<SkillGroupSummary>.Success(ToSkillGroupSummary(group));
}
}
public ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
if (!m_SkillGroupsById.TryGetValue(skillGroupId, out var group))
return ServiceResult<bool>.Failure("skill_group_not_found", "Skill group was not found.");
var character = m_CharactersById[group.CharacterId];
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
return ServiceResult<bool>.Failure(campaignError!.Code, campaignError.Message);
if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<bool>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
foreach (var skill in m_SkillsById.Values.Where(skill => skill.SkillGroupId == group.Id))
skill.SkillGroupId = null;
m_SkillGroupsById.Remove(group.Id);
TouchCharacterLocked(campaign.Id, character.Id);
PersistStateLocked();
return ServiceResult<bool>.Success(true);
}
}
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null)
{
if (string.IsNullOrWhiteSpace(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.");
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
return ServiceResult<SkillSummary>.Failure(campaignError!.Code, campaignError.Message);
if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
var skillValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange);
if (!skillValidation.Succeeded)
return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
var resolvedSkillGroupId = ResolveSkillGroupForSkillChangeLocked(skillGroupId, character.Id);
if (!resolvedSkillGroupId.Succeeded)
return ServiceResult<SkillSummary>.Failure(resolvedSkillGroupId.Error!.Code, resolvedSkillGroupId.Error.Message);
var skill = new Skill
{
Id = Guid.NewGuid(),
CharacterId = character.Id,
SkillGroupId = resolvedSkillGroupId.Value,
Name = name.Trim(),
DiceRollDefinition = skillValidation.Value!.CanonicalExpression,
WildDice = skillValidation.Value.WildDice,
AllowFumble = skillValidation.Value.AllowFumble,
FumbleRange = skillValidation.Value.FumbleRange
};
m_SkillsById[skill.Id] = skill;
TouchCharacterLocked(campaign.Id, character.Id);
PersistStateLocked();
return ServiceResult<SkillSummary>.Success(ToSkillSummary(skill));
}
}
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null)
{
if (string.IsNullOrWhiteSpace(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];
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
return ServiceResult<SkillSummary>.Failure(campaignError!.Code, campaignError.Message);
if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
var skillValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange);
if (!skillValidation.Succeeded)
return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
var resolvedSkillGroupId = ResolveSkillGroupForSkillChangeLocked(skillGroupId, character.Id);
if (!resolvedSkillGroupId.Succeeded)
return ServiceResult<SkillSummary>.Failure(resolvedSkillGroupId.Error!.Code, resolvedSkillGroupId.Error.Message);
skill.Name = name.Trim();
skill.DiceRollDefinition = skillValidation.Value!.CanonicalExpression;
skill.WildDice = skillValidation.Value.WildDice;
skill.AllowFumble = skillValidation.Value.AllowFumble;
skill.FumbleRange = skillValidation.Value.FumbleRange;
skill.SkillGroupId = resolvedSkillGroupId.Value;
TouchCharacterLocked(campaign.Id, character.Id);
PersistStateLocked();
return ServiceResult<SkillSummary>.Success(ToSkillSummary(skill));
}
}
public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
if (!m_SkillsById.TryGetValue(skillId, out var skill))
return ServiceResult<bool>.Failure("skill_not_found", "Skill was not found.");
var character = m_CharactersById[skill.CharacterId];
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
return ServiceResult<bool>.Failure(campaignError!.Code, campaignError.Message);
if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<bool>.Failure("forbidden", "Only the owner or GM can edit skills.");
m_SkillsById.Remove(skill.Id);
TouchCharacterLocked(campaign.Id, character.Id);
PersistStateLocked();
return ServiceResult<bool>.Success(true);
}
}
public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<CharacterSheet>.Failure("unauthorized", "You must be logged in.");
if (!m_CharactersById.TryGetValue(characterId, out var character))
return ServiceResult<CharacterSheet>.Failure("character_not_found", "Character was not found.");
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
return ServiceResult<CharacterSheet>.Failure(campaignError!.Code, campaignError.Message);
if (!CanViewCampaignLocked(user.Id, campaign.Id))
return ServiceResult<CharacterSheet>.Failure("forbidden", "You are not a participant in this campaign.");
return ServiceResult<CharacterSheet>.Success(ToCharacterSheet(character.Id));
}
}
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility)
{
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];
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
return ServiceResult<RollResult>.Failure(campaignError!.Code, campaignError.Message);
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 parsedVisibility = ParseVisibility(visibility);
if (!parsedVisibility.Succeeded)
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message);
var roll = ComputeRoll(campaign.Ruleset, parsedExpression.Value!, skill);
var entry = new RollLogEntry
{
Id = Guid.NewGuid(),
CampaignId = campaign.Id,
CharacterId = character.Id,
SkillId = skill.Id,
RollerUserId = user.Id,
Visibility = parsedVisibility.Value,
Result = roll.Total,
Breakdown = roll.Breakdown,
Dice = SerializeDice(roll.Dice),
TimestampUtc = DateTimeOffset.UtcNow
};
m_RollLog.Add(entry);
TouchLogLocked(campaign.Id);
PersistStateLocked();
return ServiceResult<RollResult>.Success(ToRollResult(entry, roll.Dice));
}
}
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 = GetVisibleCampaignLogEntriesLocked(user, campaign)
.TakeLast(CampaignLogHistoryWindowSize)
.Select(ToLogEntry)
.ToArray();
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries);
}
}
public ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null)
{
lock (m_Gate)
{
var context = ResolveContextLocked(sessionToken, campaignId);
if (!context.Succeeded)
return ServiceResult<CampaignLogPage>.Failure(context.Error!.Code, context.Error.Message);
var (user, campaign) = context.Value!;
var pageSize = NormalizeCampaignLogPageSize(limit);
var visibleEntries = GetVisibleCampaignLogEntriesLocked(user, campaign).ToArray();
if (!afterRollId.HasValue)
{
var initialEntries = visibleEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(initialEntries, initialEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, false));
}
var afterIndex = Array.FindIndex(visibleEntries, entry => entry.Id == afterRollId.Value);
if (afterIndex < 0)
{
var replacementEntries = visibleEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(replacementEntries, replacementEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, true));
}
var newEntries = visibleEntries.Skip(afterIndex + 1).ToArray();
if (newEntries.Length == 0)
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage([], afterRollId, false, false));
if (newEntries.Length > pageSize)
{
var replacementEntries = newEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(replacementEntries, replacementEntries[^1].RollId, true, true));
}
var appendedEntries = newEntries.Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(appendedEntries, appendedEntries[^1].RollId, false, false));
}
}
public ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<CampaignRollDetail>.Failure("unauthorized", "You must be logged in.");
var entry = m_RollLog.FirstOrDefault(candidate => candidate.Id == rollId);
if (entry is null)
return ServiceResult<CampaignRollDetail>.Failure("roll_not_found", "Roll was not found.");
if (!m_CampaignsById.TryGetValue(entry.CampaignId, out var campaign) || !CanViewRollLocked(user, campaign, entry))
return ServiceResult<CampaignRollDetail>.Failure("roll_not_found", "Roll was not found.");
return ServiceResult<CampaignRollDetail>.Success(new CampaignRollDetail(entry.Id, entry.Breakdown, DeserializeDice(entry.Dice).ToArray()));
}
}
public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId)
{
lock (m_Gate)
{
var context = ResolveContextLocked(sessionToken, campaignId);
if (!context.Succeeded)
return ServiceResult<CampaignStateSnapshot>.Failure(context.Error!.Code, context.Error.Message);
return ServiceResult<CampaignStateSnapshot>.Success(ToCampaignStateSnapshot(context.Value!.Campaign));
}
}
private static ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)> ValidateSkillDefinition(RulesetKind ruleset, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange)
{
var expressionValidation = DiceRules.ParseExpression(ruleset, diceRollDefinition);
if (!expressionValidation.Succeeded)
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
var optionsValidation = ValidateSkillOptions(ruleset, expressionValidation.Value!, wildDice, allowFumble, fumbleRange);
if (!optionsValidation.Succeeded)
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value!.WildDice, optionsValidation.Value.AllowFumble, optionsValidation.Value.FumbleRange));
}
private static ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)> ValidateSkillOptions(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange)
{
if (wildDice < 0 || wildDice > 50)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 50.");
if (ruleset == RulesetKind.D6)
{
if (wildDice < 1)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_wild_dice", "D6 skills require at least one wild die.");
if (fumbleRange.HasValue)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range is only supported for Rolemaster open-ended percentile skills.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((wildDice, allowFumble, null));
}
if (ruleset == RulesetKind.Rolemaster)
{
if (wildDice != 0)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_wild_dice", "Rolemaster skills do not support wild dice.");
if (allowFumble)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_allow_fumble", "Rolemaster skills do not use the D6 allow-fumble option.");
if (expression.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile)
{
if (!fumbleRange.HasValue)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Rolemaster open-ended percentile skills require a fumble range.");
if (fumbleRange < 0 || fumbleRange >= 96)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range must be between 0 and 95.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((0, false, fumbleRange));
}
if (fumbleRange.HasValue)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range is only valid for Rolemaster open-ended percentile skills.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((0, false, null));
}
if (fumbleRange.HasValue)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range is only supported for Rolemaster open-ended percentile skills.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((0, false, null));
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, Skill skill)
{
return ruleset == RulesetKind.D6 ? ComputeD6Roll(expression, skill.WildDice, skill.AllowFumble) : ComputeStandardRoll(expression);
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeStandardRoll(DiceExpression expression)
{
var diceValues = new int[expression.DiceCount];
var dice = new RollDieResult[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;
dice[i] = new(value, false, false, false, false, false);
total += value;
}
return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice);
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeD6Roll(DiceExpression expression, int wildDice, bool allowFumble)
{
var initialDice = expression.DiceCount;
var currentDice = initialDice;
var pendingExplodingDice = 0;
var pendingFumbles = 0;
var dieResults = new List<RollDieResult>(initialDice);
for (var i = 0; i < currentDice; i += 1)
{
var roll = m_DiceRoller.Roll(expression.Sides);
var isWild = i < wildDice;
var isCrit = false;
var isFumble = false;
var isAdded = false;
if (isWild)
{
if (roll == expression.Sides)
{
pendingExplodingDice += 1;
currentDice += 1;
isCrit = true;
}
else if (allowFumble && roll == 1)
{
pendingFumbles += 1;
isFumble = true;
}
}
if (pendingExplodingDice > 0 && i >= initialDice)
{
pendingExplodingDice -= 1;
isAdded = true;
if (roll == expression.Sides)
{
pendingExplodingDice += 1;
currentDice += 1;
}
}
dieResults.Add(new(roll, isCrit, isFumble, isWild, false, isAdded));
}
for (var roll = expression.Sides; roll >= 1 && pendingFumbles > 0; roll -= 1)
for (var i = currentDice - 1; i >= 0 && pendingFumbles > 0; i -= 1)
{
if (dieResults[i].Roll != roll)
continue;
dieResults[i] = dieResults[i] with
{
Removed = true,
Added = false,
Crit = false,
Fumble = false
};
pendingFumbles -= 1;
}
var total = expression.Modifier;
var includedDice = new List<int>(dieResults.Count);
foreach (var die in dieResults)
{
if (die.Fumble)
{
total += 1;
includedDice.Add(1);
}
else if (!die.Removed)
{
total += die.Roll;
includedDice.Add(die.Roll);
}
}
return (total, BuildBreakdown(includedDice, expression.Modifier, total), dieResults);
}
private static string BuildBreakdown(IEnumerable<int> diceValues, int modifier, int total)
{
var dicePart = string.Join("+", diceValues);
if (string.IsNullOrWhiteSpace(dicePart))
dicePart = "0";
var modifierPart = modifier > 0 ? $"+{modifier}" : string.Empty;
return $"{dicePart}{modifierPart}={total}";
}
private ServiceResult<Guid?> ResolveSkillGroupForSkillChangeLocked(Guid? requestedSkillGroupId, Guid characterId)
{
if (!requestedSkillGroupId.HasValue)
return ServiceResult<Guid?>.Success(null);
if (!m_SkillGroupsById.TryGetValue(requestedSkillGroupId.Value, out var skillGroup))
return ServiceResult<Guid?>.Failure("skill_group_not_found", "Skill group was not found.");
if (skillGroup.CharacterId != characterId)
return ServiceResult<Guid?>.Failure("invalid_skill_group", "Skill group must belong to the same character.");
return ServiceResult<Guid?>.Success(skillGroup.Id);
}
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(user.Id, user.Username, user.DisplayName, ParseRoles(user.Roles));
}
private static AdminUserSummary ToAdminUserSummary(UserAccount user)
{
return new(user.Id, user.Username, user.DisplayName, ParseRoles(user.Roles));
}
private static CampaignOption ToCampaignOption(Campaign campaign)
{
return new(campaign.Id, campaign.Name);
}
private CampaignSummary ToCampaignSummary(Campaign campaign)
{
var gm = m_UsersById[campaign.GmUserId];
var characterCount = m_CharactersById.Values.Count(character => character.CampaignId == campaign.Id);
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToCampaignGmSummary(gm), characterCount);
}
private CampaignRoster ToCampaignRoster(Campaign campaign)
{
var gm = m_UsersById[campaign.GmUserId];
var characters = m_CharactersById.Values
.Where(character => character.CampaignId == campaign.Id)
.OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCharacterSummary)
.ToArray();
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToCampaignGmSummary(gm), characters);
}
private CharacterSheet ToCharacterSheet(Guid characterId)
{
var skillGroups = m_SkillGroupsById.Values
.Where(group => group.CharacterId == characterId)
.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCharacterSheetSkillGroup)
.ToArray();
var skills = m_SkillsById.Values
.Where(skill => skill.CharacterId == characterId)
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCharacterSheetSkill)
.ToArray();
return new(characterId, skillGroups, skills);
}
private CharacterSummary ToCharacterSummary(Character character)
{
var ownerDisplayName = ResolveOwnerDisplayName(character.OwnerUserId);
return new(character.Id, character.Name, character.OwnerUserId, character.CampaignId, ownerDisplayName);
}
private CampaignStateSnapshot ToCampaignStateSnapshot(Campaign campaign)
{
var state = GetOrCreateCampaignStateLocked(campaign.Id);
var characterVersions = state.CharacterVersions
.OrderBy(version => version.Key)
.Select(version => new CharacterStateVersion(version.Key, version.Value))
.ToArray();
return new CampaignStateSnapshot(campaign.Id, state.TotalVersion, state.RosterVersion, state.LogVersion, characterVersions);
}
private IEnumerable<RollLogEntry> GetVisibleCampaignLogEntriesLocked(UserAccount user, Campaign campaign)
{
return 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);
}
private static CampaignGmSummary ToCampaignGmSummary(UserAccount user)
{
return new(user.Id, user.DisplayName);
}
private static CharacterSheetSkillGroup ToCharacterSheetSkillGroup(SkillGroup skillGroup)
{
return new(skillGroup.Id, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.FumbleRange);
}
private static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup)
{
return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.FumbleRange);
}
private static CharacterSheetSkill ToCharacterSheetSkill(Skill skill)
{
return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange);
}
private static SkillSummary ToSkillSummary(Skill skill)
{
return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange);
}
private static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList<RollDieResult> dice)
{
return new(entry.Id, entry.CampaignId, entry.CharacterId, entry.SkillId, entry.RollerUserId, entry.Visibility == RollVisibility.Public ? "public" : "private", entry.Result, entry.Breakdown, dice, entry.TimestampUtc);
}
private CampaignLogEntry ToLogEntry(RollLogEntry entry)
{
var dice = DeserializeDice(entry.Dice);
var characterName = m_CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character";
var skillName = m_SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.Name : "Unknown skill";
var rollerDisplayName = ResolveOwnerDisplayName(entry.RollerUserId);
return new(
entry.Id,
entry.CampaignId,
entry.CharacterId,
characterName,
entry.SkillId,
skillName,
entry.RollerUserId,
rollerDisplayName,
entry.Visibility == RollVisibility.Public ? "public" : "private",
entry.Result,
entry.Breakdown,
dice,
entry.TimestampUtc);
}
private CampaignLogListEntry ToLogListEntry(UserAccount user, Campaign campaign, RollLogEntry entry)
{
var dice = DeserializeDice(entry.Dice);
var characterName = m_CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character";
var skillName = m_SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.Name : "Unknown skill";
return new(
entry.Id,
characterName,
skillName,
ResolveLogRollerLabel(user, campaign, entry),
ResolveLogVisibilityLabel(user, campaign, entry),
ResolveLogVisibilityStyle(user, campaign, entry),
entry.Result,
BuildCompactLogSummary(dice),
entry.TimestampUtc);
}
private static string SerializeDice(IReadOnlyList<RollDieResult> dice)
{
return JsonSerializer.Serialize(dice, DiceJsonOptions);
}
private static string BuildCompactLogSummary(IReadOnlyList<RollDieResult> dice)
{
if (dice.Count == 0)
return "No detail available.";
var preview = string.Join(", ", dice.Take(3).Select(die => die.Roll.ToString()));
if (dice.Count > 3)
preview = $"{preview}, ...";
var tags = new List<string>();
if (dice.Any(die => die.Wild))
tags.Add("wild");
if (dice.Any(die => die.Crit))
tags.Add("crit");
if (dice.Any(die => die.Fumble))
tags.Add("fumble");
return tags.Count == 0 ? preview : $"{preview} | {string.Join(", ", tags)}";
}
private bool CanViewRollLocked(UserAccount user, Campaign campaign, RollLogEntry entry)
{
return CanViewCampaignLocked(user.Id, campaign.Id) &&
(entry.Visibility == RollVisibility.Public || entry.RollerUserId == user.Id || campaign.GmUserId == user.Id);
}
private string ResolveLogRollerLabel(UserAccount user, Campaign campaign, RollLogEntry entry)
{
if (entry.RollerUserId == user.Id)
return "You";
if (entry.RollerUserId == campaign.GmUserId)
return "GM";
return ResolveOwnerDisplayName(entry.RollerUserId);
}
private static string ResolveLogVisibilityLabel(UserAccount user, Campaign campaign, RollLogEntry entry)
{
if (entry.Visibility != RollVisibility.Private)
return "Public";
if (entry.RollerUserId == user.Id)
return "Private (you)";
return campaign.GmUserId == user.Id ? "Private (GM view)" : "Private";
}
private static string ResolveLogVisibilityStyle(UserAccount user, Campaign campaign, RollLogEntry entry)
{
if (entry.Visibility != RollVisibility.Private)
return "public";
if (entry.RollerUserId == user.Id)
return "private-self";
return campaign.GmUserId == user.Id ? "private-gm" : "private-generic";
}
private string ResolveOwnerDisplayName(Guid ownerUserId)
{
return m_UsersById.TryGetValue(ownerUserId, out var owner) && !string.IsNullOrWhiteSpace(owner.DisplayName)
? owner.DisplayName
: "Unknown owner";
}
private static IReadOnlyList<RollDieResult> DeserializeDice(string serializedDice)
{
if (string.IsNullOrWhiteSpace(serializedDice))
return [];
try
{
return JsonSerializer.Deserialize<IReadOnlyList<RollDieResult>>(serializedDice, DiceJsonOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
private bool CanEditCharacterLocked(Guid actorUserId, Character character, Campaign campaign)
{
return character.OwnerUserId == actorUserId || campaign.GmUserId == actorUserId;
}
private bool CanViewCampaignLocked(Guid userId, Guid campaignId)
{
if (m_UsersById.TryGetValue(userId, out var user) && UserHasRoleLocked(user, UserRoles.Admin))
return true;
var campaign = m_CampaignsById[campaignId];
if (campaign.GmUserId == userId)
return true;
return m_CharactersById.Values.Any(c => c.CampaignId.HasValue && c.CampaignId.Value == campaignId && c.OwnerUserId == userId);
}
private bool TryGetCurrentCampaignIdLocked(UserAccount user, out Guid campaignId)
{
campaignId = Guid.Empty;
if (user.ActiveCharacterId is not Guid activeCharacterId)
return false;
if (!m_CharactersById.TryGetValue(activeCharacterId, out var character))
{
user.ActiveCharacterId = null;
PersistStateLocked();
return false;
}
if (!character.CampaignId.HasValue)
return false;
campaignId = character.CampaignId.Value;
return true;
}
private bool TryResolveCharacterCampaignLocked(Character character, out Campaign campaign, out ServiceError? error)
{
campaign = default!;
if (!character.CampaignId.HasValue || !m_CampaignsById.TryGetValue(character.CampaignId.Value, out var resolvedCampaign) || resolvedCampaign is null)
{
error = new ServiceError("character_not_in_campaign", "Character is not linked to a campaign.");
return false;
}
campaign = resolvedCampaign;
error = null;
return true;
}
private void DeleteCampaignLocked(Guid campaignId)
{
if (!m_CampaignsById.Remove(campaignId))
return;
var affectedCharacterIds = m_CharactersById.Values.Where(character => character.CampaignId == campaignId).Select(character => character.Id).ToArray();
foreach (var characterId in affectedCharacterIds)
m_CharactersById[characterId].CampaignId = null;
m_RollLog.RemoveAll(entry => entry.CampaignId == campaignId);
m_CampaignStateById.Remove(campaignId);
}
private void DeleteCharacterLocked(Guid characterId)
{
if (!m_CharactersById.TryGetValue(characterId, out var character))
return;
var campaignId = character.CampaignId;
m_CharactersById.Remove(characterId);
var skillGroupIds = m_SkillGroupsById.Values.Where(group => group.CharacterId == characterId).Select(group => group.Id).ToHashSet();
foreach (var skillGroupId in skillGroupIds)
m_SkillGroupsById.Remove(skillGroupId);
var skillIds = m_SkillsById.Values.Where(skill => skill.CharacterId == characterId).Select(skill => skill.Id).ToHashSet();
foreach (var skillId in skillIds)
m_SkillsById.Remove(skillId);
m_RollLog.RemoveAll(entry => entry.CharacterId == characterId || skillIds.Contains(entry.SkillId));
foreach (var user in m_UsersById.Values.Where(account => account.ActiveCharacterId == characterId))
user.ActiveCharacterId = null;
RemoveCharacterStateLocked(campaignId, characterId);
TouchRosterLocked(campaignId);
}
private static IReadOnlyList<string> ParseRoles(string serializedRoles)
{
return NormalizeRoles(serializedRoles.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
}
private static string SerializeRoles(IReadOnlyList<string> roles)
{
return string.Join(",", NormalizeRoles(roles));
}
private static string[] NormalizeRoles(IEnumerable<string> roles)
{
return roles
.Where(role => !string.IsNullOrWhiteSpace(role))
.Select(role => role.Trim().ToLowerInvariant())
.Distinct(StringComparer.Ordinal)
.OrderBy(role => role, StringComparer.Ordinal)
.ToArray();
}
private static bool UserHasRoleLocked(UserAccount user, string role)
{
return ParseRoles(user.Roles).Contains(role, StringComparer.OrdinalIgnoreCase);
}
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 CampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId)
{
if (!m_CampaignStateById.TryGetValue(campaignId, out var state))
{
state = new CampaignStateTracker();
m_CampaignStateById[campaignId] = state;
}
return state;
}
private void AddCharacterStateLocked(Guid? campaignId, Guid characterId)
{
if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value))
return;
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
state.CharacterVersions[characterId] = 1;
}
private void RemoveCharacterStateLocked(Guid? campaignId, Guid characterId)
{
if (!campaignId.HasValue || !m_CampaignStateById.TryGetValue(campaignId.Value, out var state))
return;
state.CharacterVersions.Remove(characterId);
}
private void TouchRosterLocked(Guid? campaignId)
{
if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value))
return;
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
state.TotalVersion += 1;
state.RosterVersion += 1;
}
private void TouchCharacterLocked(Guid? campaignId, Guid characterId)
{
if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value))
return;
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
state.TotalVersion += 1;
state.CharacterVersions[characterId] = state.CharacterVersions.GetValueOrDefault(characterId, 1) + 1;
}
private void TouchLogLocked(Guid? campaignId)
{
if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value))
return;
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
state.TotalVersion += 1;
state.LogVersion += 1;
}
private void RebuildCampaignStateLocked()
{
m_CampaignStateById.Clear();
foreach (var campaignId in m_CampaignsById.Keys)
m_CampaignStateById[campaignId] = new CampaignStateTracker();
foreach (var character in m_CharactersById.Values.Where(character => character.CampaignId.HasValue))
AddCharacterStateLocked(character.CampaignId, character.Id);
}
private void LoadStateFromDatabase()
{
using var db = m_DbContextFactory.CreateDbContext();
var users = db.Users.AsNoTracking().ToList();
var sessions = db.Sessions.AsNoTracking().ToList();
var campaigns = db.Campaigns.AsNoTracking().ToList();
var characters = db.Characters.AsNoTracking().ToList();
var skillGroups = db.SkillGroups.AsNoTracking().ToList();
var skills = db.Skills.AsNoTracking().ToList();
var logEntries = db.RollLogEntries.AsNoTracking().ToList().OrderBy(x => x.TimestampUtc).ThenBy(x => x.Id).ToList();
lock (m_Gate)
{
m_UsersById.Clear();
m_UserIdsByUsername.Clear();
m_SessionsByToken.Clear();
m_CampaignsById.Clear();
m_CharactersById.Clear();
m_SkillGroupsById.Clear();
m_SkillsById.Clear();
m_RollLog.Clear();
foreach (var user in users)
{
var normalizedUsername = string.IsNullOrWhiteSpace(user.UsernameNormalized) ? NormalizeUsername(user.Username) : user.UsernameNormalized;
var storedUser = new UserAccount
{
Id = user.Id,
Username = user.Username,
UsernameNormalized = normalizedUsername,
PasswordHash = user.PasswordHash,
DisplayName = user.DisplayName,
Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : SerializeRoles(ParseRoles(user.Roles)),
ActiveCharacterId = user.ActiveCharacterId
};
m_UsersById[storedUser.Id] = storedUser;
m_UserIdsByUsername[normalizedUsername] = storedUser.Id;
}
foreach (var session in sessions)
{
if (m_UsersById.ContainsKey(session.UserId))
m_SessionsByToken[session.Token] = CloneSession(session);
}
foreach (var campaign in campaigns)
m_CampaignsById[campaign.Id] = CloneCampaign(campaign);
foreach (var character in characters)
m_CharactersById[character.Id] = CloneCharacter(character);
foreach (var skillGroup in skillGroups)
m_SkillGroupsById[skillGroup.Id] = CloneSkillGroup(skillGroup);
foreach (var skill in skills)
m_SkillsById[skill.Id] = CloneSkill(skill);
m_RollLog.AddRange(logEntries.Select(CloneRollLogEntry));
RebuildCampaignStateLocked();
}
}
private void PersistStateLocked()
{
using var db = m_DbContextFactory.CreateDbContext();
using var transaction = db.Database.BeginTransaction();
db.RollLogEntries.ExecuteDelete();
db.Skills.ExecuteDelete();
db.SkillGroups.ExecuteDelete();
db.Characters.ExecuteDelete();
db.Campaigns.ExecuteDelete();
db.Sessions.ExecuteDelete();
db.Users.ExecuteDelete();
db.Users.AddRange(m_UsersById.Values.Select(CloneUser));
db.Sessions.AddRange(m_SessionsByToken.Values.Select(CloneSession));
db.Campaigns.AddRange(m_CampaignsById.Values.Select(CloneCampaign));
db.Characters.AddRange(m_CharactersById.Values.Select(CloneCharacter));
db.SkillGroups.AddRange(m_SkillGroupsById.Values.Select(CloneSkillGroup));
db.Skills.AddRange(m_SkillsById.Values.Select(CloneSkill));
db.RollLogEntries.AddRange(m_RollLog.Select(CloneRollLogEntry));
db.SaveChanges();
transaction.Commit();
}
private static string NormalizeUsername(string username)
{
return username.ToUpperInvariant();
}
private static int NormalizeCampaignLogPageSize(int? limit)
{
if (!limit.HasValue)
return CampaignLogLivePageSize;
return Math.Clamp(limit.Value, 1, CampaignLogLivePageSize);
}
private static UserAccount CloneUser(UserAccount user)
{
return new()
{
Id = user.Id,
Username = user.Username,
UsernameNormalized = user.UsernameNormalized,
PasswordHash = user.PasswordHash,
DisplayName = user.DisplayName,
Roles = user.Roles,
ActiveCharacterId = user.ActiveCharacterId
};
}
private static UserSession CloneSession(UserSession session)
{
return new()
{
Token = session.Token,
UserId = session.UserId,
CreatedAtUtc = session.CreatedAtUtc
};
}
private static Campaign CloneCampaign(Campaign campaign)
{
return new()
{
Id = campaign.Id,
GmUserId = campaign.GmUserId,
Name = campaign.Name,
Ruleset = campaign.Ruleset,
Version = campaign.Version
};
}
private static Character CloneCharacter(Character character)
{
return new()
{
Id = character.Id,
OwnerUserId = character.OwnerUserId,
CampaignId = character.CampaignId,
Name = character.Name
};
}
private static Skill CloneSkill(Skill skill)
{
return new()
{
Id = skill.Id,
CharacterId = skill.CharacterId,
SkillGroupId = skill.SkillGroupId,
Name = skill.Name,
DiceRollDefinition = skill.DiceRollDefinition,
WildDice = skill.WildDice,
AllowFumble = skill.AllowFumble,
FumbleRange = skill.FumbleRange
};
}
private static SkillGroup CloneSkillGroup(SkillGroup skillGroup)
{
return new()
{
Id = skillGroup.Id,
CharacterId = skillGroup.CharacterId,
Name = skillGroup.Name,
DiceRollDefinition = skillGroup.DiceRollDefinition,
WildDice = skillGroup.WildDice,
AllowFumble = skillGroup.AllowFumble,
FumbleRange = skillGroup.FumbleRange
};
}
private static RollLogEntry CloneRollLogEntry(RollLogEntry entry)
{
return new()
{
Id = entry.Id,
CampaignId = entry.CampaignId,
CharacterId = entry.CharacterId,
SkillId = entry.SkillId,
RollerUserId = entry.RollerUserId,
Visibility = entry.Visibility,
Result = entry.Result,
Breakdown = entry.Breakdown,
Dice = entry.Dice,
TimestampUtc = entry.TimestampUtc
};
}
private const int CampaignLogHistoryWindowSize = 100;
private const int CampaignLogLivePageSize = 25;
private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions();
private readonly Dictionary<Guid, Campaign> m_CampaignsById = [];
private readonly Dictionary<Guid, CampaignStateTracker> m_CampaignStateById = [];
private readonly Dictionary<Guid, Character> m_CharactersById = [];
private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory;
private readonly IDiceRoller m_DiceRoller;
private readonly object m_Gate = new();
private readonly IPasswordHasher<UserAccount> m_PasswordHasher;
private readonly List<RollLogEntry> m_RollLog = [];
private readonly Dictionary<string, UserSession> m_SessionsByToken = new(StringComparer.Ordinal);
private readonly Dictionary<Guid, SkillGroup> m_SkillGroupsById = [];
private readonly Dictionary<Guid, Skill> m_SkillsById = [];
private readonly Dictionary<string, Guid> m_UserIdsByUsername = new(StringComparer.Ordinal);
private readonly Dictionary<Guid, UserAccount> m_UsersById = [];
private sealed class CampaignStateTracker
{
public long TotalVersion { get; set; } = 1;
public long RosterVersion { get; set; } = 1;
public long LogVersion { get; set; } = 1;
public Dictionary<Guid, long> CharacterVersions { get; } = [];
}
}