Extract game skill service
This commit is contained in:
@@ -25,6 +25,7 @@ Backend:
|
||||
- `RpgRoller/Services/GameAuthService.cs`: extracted auth/session workflow ownership while `GameService` stays on the existing `IGameService` contract
|
||||
- `RpgRoller/Services/GameCampaignService.cs`: extracted campaign creation, visibility reads, roster reads, and deletion workflows behind the same facade contract
|
||||
- `RpgRoller/Services/GameCharacterService.cs`: extracted character creation, transfer, activation, deletion, and owner-scoped listing workflows behind the same facade contract
|
||||
- `RpgRoller/Services/GameSkillService.cs`: extracted skill-group CRUD, skill CRUD, sheet shaping, and ruleset-specific validation orchestration behind the same facade contract
|
||||
|
||||
Frontend:
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
375
RpgRoller/Services/GameSkillService.cs
Normal file
375
RpgRoller/Services/GameSkillService.cs
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user