1757 lines
74 KiB
C#
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; } = [];
|
|
}
|
|
}
|