Extract game skill service

This commit is contained in:
2026-04-04 23:30:44 +02:00
parent f6046e65f8
commit 9479b2e2f3
3 changed files with 385 additions and 278 deletions

View File

@@ -26,6 +26,7 @@ public sealed class GameService : IGameService
m_AuthService = new(m_StateStore, passwordHasher, m_PersistenceService);
m_CampaignService = new(m_StateStore, m_PersistenceService);
m_CharacterService = new(m_StateStore, m_PersistenceService);
m_SkillService = new(m_StateStore, m_PersistenceService);
m_DiceRoller = diceRoller;
LoadStateFromDatabase();
}
@@ -219,248 +220,37 @@ public sealed class GameService : IGameService
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 = SkillDefinitionValidator.Validate(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));
}
return m_SkillService.CreateSkillGroup(sessionToken, characterId, name, diceRollDefinition, wildDice, allowFumble, fumbleRange);
}
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 = SkillDefinitionValidator.Validate(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));
}
return m_SkillService.UpdateSkillGroup(sessionToken, skillGroupId, name, diceRollDefinition, wildDice, allowFumble, fumbleRange);
}
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);
}
return m_SkillService.DeleteSkillGroup(sessionToken, skillGroupId);
}
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 = SkillDefinitionValidator.Validate(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));
}
return m_SkillService.CreateSkill(sessionToken, characterId, name, diceRollDefinition, wildDice, allowFumble, skillGroupId, fumbleRange);
}
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 = SkillDefinitionValidator.Validate(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));
}
return m_SkillService.UpdateSkill(sessionToken, skillId, name, diceRollDefinition, wildDice, allowFumble, skillGroupId, fumbleRange);
}
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);
}
return m_SkillService.DeleteSkill(sessionToken, skillId);
}
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));
}
return m_SkillService.GetCharacterSheet(sessionToken, characterId);
}
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility)
@@ -848,20 +638,6 @@ public sealed class GameService : IGameService
};
}
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<RollResult> RecordRollLocked(
UserAccount user,
Campaign campaign,
@@ -924,22 +700,6 @@ public sealed class GameService : IGameService
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles));
}
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 CampaignStateSnapshot ToCampaignStateSnapshot(Campaign campaign)
{
var state = GetOrCreateCampaignStateLocked(campaign.Id);
@@ -960,26 +720,6 @@ public sealed class GameService : IGameService
.ThenBy(r => r.Id);
}
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);
@@ -1362,16 +1102,6 @@ public sealed class GameService : IGameService
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))
@@ -1430,6 +1160,7 @@ public sealed class GameService : IGameService
private readonly GamePersistenceService m_PersistenceService;
private readonly List<RollLogEntry> m_RollLog;
private readonly Dictionary<string, UserSession> m_SessionsByToken;
private readonly GameSkillService m_SkillService;
private readonly Dictionary<Guid, SkillGroup> m_SkillGroupsById;
private readonly Dictionary<Guid, Skill> m_SkillsById;
private readonly GameStateStore m_StateStore;

View File

@@ -0,0 +1,375 @@
using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class GameSkillService
{
public GameSkillService(GameStateStore stateStore, GamePersistenceService persistenceService)
{
m_StateStore = stateStore;
m_PersistenceService = persistenceService;
}
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_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<SkillGroupSummary>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.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 = SkillDefinitionValidator.Validate(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_StateStore.SkillGroupsById[group.Id] = group;
TouchCharacterLocked(campaign.Id, character.Id);
m_PersistenceService.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_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<SkillGroupSummary>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.SkillGroupsById.TryGetValue(skillGroupId, out var group))
return ServiceResult<SkillGroupSummary>.Failure("skill_group_not_found", "Skill group was not found.");
var character = m_StateStore.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 = SkillDefinitionValidator.Validate(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);
m_PersistenceService.PersistStateLocked();
return ServiceResult<SkillGroupSummary>.Success(ToSkillGroupSummary(group));
}
}
public ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId)
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.SkillGroupsById.TryGetValue(skillGroupId, out var group))
return ServiceResult<bool>.Failure("skill_group_not_found", "Skill group was not found.");
var character = m_StateStore.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_StateStore.SkillsById.Values.Where(skill => skill.SkillGroupId == group.Id))
skill.SkillGroupId = null;
m_StateStore.SkillGroupsById.Remove(group.Id);
TouchCharacterLocked(campaign.Id, character.Id);
m_PersistenceService.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_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.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 = SkillDefinitionValidator.Validate(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_StateStore.SkillsById[skill.Id] = skill;
TouchCharacterLocked(campaign.Id, character.Id);
m_PersistenceService.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_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.SkillsById.TryGetValue(skillId, out var skill))
return ServiceResult<SkillSummary>.Failure("skill_not_found", "Skill was not found.");
var character = m_StateStore.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 = SkillDefinitionValidator.Validate(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);
m_PersistenceService.PersistStateLocked();
return ServiceResult<SkillSummary>.Success(ToSkillSummary(skill));
}
}
public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId)
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.SkillsById.TryGetValue(skillId, out var skill))
return ServiceResult<bool>.Failure("skill_not_found", "Skill was not found.");
var character = m_StateStore.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_StateStore.SkillsById.Remove(skill.Id);
TouchCharacterLocked(campaign.Id, character.Id);
m_PersistenceService.PersistStateLocked();
return ServiceResult<bool>.Success(true);
}
}
public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId)
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<CharacterSheet>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.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));
}
}
private UserAccount? ResolveUserLocked(string sessionToken)
{
if (string.IsNullOrWhiteSpace(sessionToken))
return null;
if (!m_StateStore.SessionsByToken.TryGetValue(sessionToken, out var session))
return null;
return m_StateStore.UsersById.GetValueOrDefault(session.UserId);
}
private bool TryResolveCharacterCampaignLocked(Character character, out Campaign campaign, out ServiceError? error)
{
campaign = default!;
if (!character.CampaignId.HasValue || !m_StateStore.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 bool CanEditCharacterLocked(Guid actorUserId, Character character, Campaign campaign)
{
return character.OwnerUserId == actorUserId || campaign.GmUserId == actorUserId;
}
private bool CanViewCampaignLocked(Guid userId, Guid campaignId)
{
if (m_StateStore.UsersById.TryGetValue(userId, out var user) && RoleSerializer.HasRole(user.Roles, UserRoles.Admin))
return true;
var campaign = m_StateStore.CampaignsById[campaignId];
if (campaign.GmUserId == userId)
return true;
return m_StateStore.CharactersById.Values.Any(c => c.CampaignId.HasValue && c.CampaignId.Value == campaignId && c.OwnerUserId == userId);
}
private ServiceResult<Guid?> ResolveSkillGroupForSkillChangeLocked(Guid? requestedSkillGroupId, Guid characterId)
{
if (!requestedSkillGroupId.HasValue)
return ServiceResult<Guid?>.Success(null);
if (!m_StateStore.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 CharacterSheet ToCharacterSheet(Guid characterId)
{
var skillGroups = m_StateStore.SkillGroupsById.Values
.Where(group => group.CharacterId == characterId)
.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCharacterSheetSkillGroup)
.ToArray();
var skills = m_StateStore.SkillsById.Values
.Where(skill => skill.CharacterId == characterId)
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCharacterSheetSkill)
.ToArray();
return new(characterId, skillGroups, skills);
}
private static CharacterSheetSkillGroup ToCharacterSheetSkillGroup(SkillGroup skillGroup)
{
return new(skillGroup.Id, 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 SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup)
{
return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.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 void TouchCharacterLocked(Guid? campaignId, Guid characterId)
{
if (!campaignId.HasValue || !m_StateStore.CampaignsById.ContainsKey(campaignId.Value))
return;
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
state.TotalVersion += 1;
state.CharacterVersions[characterId] = state.CharacterVersions.GetValueOrDefault(characterId, 1) + 1;
}
private GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId)
{
if (!m_StateStore.CampaignStateById.TryGetValue(campaignId, out var state))
{
state = new GameCampaignStateTracker();
m_StateStore.CampaignStateById[campaignId] = state;
}
return state;
}
private readonly GamePersistenceService m_PersistenceService;
private readonly GameStateStore m_StateStore;
}