Extract game campaign service
This commit is contained in:
@@ -23,6 +23,7 @@ Backend:
|
||||
- `RpgRoller/Services/SkillDefinitionValidator.cs`, `RoleSerializer.cs`, `RollVisibilityParser.cs`, and `CustomRollOptionsResolver.cs`: extracted pure backend rule helpers used by `GameService`
|
||||
- `RpgRoller/Services/GameStateStore.cs`, `GameStateCloneFactory.cs`, and `GamePersistenceService.cs`: extracted runtime-state ownership and SQLite load/save boundaries used by `GameService`
|
||||
- `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
|
||||
|
||||
Frontend:
|
||||
|
||||
|
||||
209
RpgRoller/Services/GameCampaignService.cs
Normal file
209
RpgRoller/Services/GameCampaignService.cs
Normal file
@@ -0,0 +1,209 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public sealed class GameCampaignService
|
||||
{
|
||||
public GameCampaignService(GameStateStore stateStore, GamePersistenceService persistenceService)
|
||||
{
|
||||
m_StateStore = stateStore;
|
||||
m_PersistenceService = persistenceService;
|
||||
}
|
||||
|
||||
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_StateStore.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_StateStore.CampaignsById[campaign.Id] = campaign;
|
||||
m_PersistenceService.PersistStateLocked();
|
||||
return ServiceResult<CampaignSummary>.Success(ToCampaignSummary(campaign));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken)
|
||||
{
|
||||
lock (m_StateStore.Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
IEnumerable<Campaign> visibleCampaigns;
|
||||
if (RoleSerializer.HasRole(user.Roles, UserRoles.Admin))
|
||||
{
|
||||
visibleCampaigns = m_StateStore.CampaignsById.Values;
|
||||
}
|
||||
else
|
||||
{
|
||||
var campaignIds = new HashSet<Guid>(m_StateStore.CampaignsById.Values.Where(campaign => campaign.GmUserId == user.Id).Select(campaign => campaign.Id));
|
||||
foreach (var character in m_StateStore.CharactersById.Values.Where(character => character.OwnerUserId == user.Id && character.CampaignId.HasValue))
|
||||
campaignIds.Add(character.CampaignId!.Value);
|
||||
|
||||
visibleCampaigns = campaignIds.Select(campaignId => m_StateStore.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_StateStore.Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<IReadOnlyList<CampaignOption>>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
var options = m_StateStore.CampaignsById.Values
|
||||
.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(campaign => new CampaignOption(campaign.Id, campaign.Name))
|
||||
.ToArray();
|
||||
|
||||
return ServiceResult<IReadOnlyList<CampaignOption>>.Success(options);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<CampaignRoster> GetCampaign(string sessionToken, Guid campaignId)
|
||||
{
|
||||
lock (m_StateStore.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_StateStore.Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!m_StateStore.CampaignsById.TryGetValue(campaignId, out var campaign))
|
||||
return ServiceResult<bool>.Failure("campaign_not_found", "Campaign was not found.");
|
||||
|
||||
if (campaign.GmUserId != user.Id && !RoleSerializer.HasRole(user.Roles, UserRoles.Admin))
|
||||
return ServiceResult<bool>.Failure("forbidden", "Only the campaign owner or admin can delete this campaign.");
|
||||
|
||||
DeleteCampaignLocked(campaignId);
|
||||
m_PersistenceService.PersistStateLocked();
|
||||
return ServiceResult<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
|
||||
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_StateStore.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 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 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 CampaignSummary ToCampaignSummary(Campaign campaign)
|
||||
{
|
||||
var gm = m_StateStore.UsersById[campaign.GmUserId];
|
||||
var characterCount = m_StateStore.CharactersById.Values.Count(character => character.CampaignId == campaign.Id);
|
||||
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), new CampaignGmSummary(gm.Id, gm.DisplayName), characterCount);
|
||||
}
|
||||
|
||||
private CampaignRoster ToCampaignRoster(Campaign campaign)
|
||||
{
|
||||
var gm = m_StateStore.UsersById[campaign.GmUserId];
|
||||
var characters = m_StateStore.CharactersById.Values
|
||||
.Where(character => character.CampaignId == campaign.Id)
|
||||
.OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(character => new CharacterSummary(
|
||||
character.Id,
|
||||
character.Name,
|
||||
character.OwnerUserId,
|
||||
character.CampaignId,
|
||||
ResolveOwnerDisplayName(character.OwnerUserId)))
|
||||
.ToArray();
|
||||
|
||||
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), new CampaignGmSummary(gm.Id, gm.DisplayName), characters);
|
||||
}
|
||||
|
||||
private string ResolveOwnerDisplayName(Guid ownerUserId)
|
||||
{
|
||||
return m_StateStore.UsersById.TryGetValue(ownerUserId, out var user)
|
||||
? user.DisplayName
|
||||
: "Unknown user";
|
||||
}
|
||||
|
||||
private void DeleteCampaignLocked(Guid campaignId)
|
||||
{
|
||||
if (!m_StateStore.CampaignsById.Remove(campaignId))
|
||||
return;
|
||||
|
||||
var affectedCharacterIds = m_StateStore.CharactersById.Values.Where(character => character.CampaignId == campaignId).Select(character => character.Id).ToArray();
|
||||
foreach (var characterId in affectedCharacterIds)
|
||||
m_StateStore.CharactersById[characterId].CampaignId = null;
|
||||
|
||||
m_StateStore.RollLog.RemoveAll(entry => entry.CampaignId == campaignId);
|
||||
m_StateStore.CampaignStateById.Remove(campaignId);
|
||||
}
|
||||
|
||||
private readonly GamePersistenceService m_PersistenceService;
|
||||
private readonly GameStateStore m_StateStore;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public sealed class GameService : IGameService
|
||||
m_UsersById = m_StateStore.UsersById;
|
||||
m_PersistenceService = new(dbContextFactory, m_StateStore);
|
||||
m_AuthService = new(m_StateStore, passwordHasher, m_PersistenceService);
|
||||
m_CampaignService = new(m_StateStore, m_PersistenceService);
|
||||
m_DiceRoller = diceRoller;
|
||||
LoadStateFromDatabase();
|
||||
}
|
||||
@@ -60,110 +61,27 @@ public sealed class GameService : IGameService
|
||||
|
||||
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));
|
||||
}
|
||||
return m_CampaignService.CreateCampaign(sessionToken, name, rulesetId);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return m_CampaignService.GetCampaigns(sessionToken);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return m_CampaignService.GetCharacterCampaignOptions(sessionToken);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
return m_CampaignService.GetCampaign(sessionToken, campaignId);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return m_CampaignService.DeleteCampaign(sessionToken, campaignId);
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken)
|
||||
@@ -1133,30 +1051,6 @@ public sealed class GameService : IGameService
|
||||
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(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
|
||||
@@ -1199,11 +1093,6 @@ public sealed class GameService : IGameService
|
||||
.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);
|
||||
@@ -1670,6 +1559,7 @@ public sealed class GameService : IGameService
|
||||
private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions();
|
||||
private readonly Dictionary<Guid, Campaign> m_CampaignsById;
|
||||
private readonly Dictionary<Guid, GameCampaignStateTracker> m_CampaignStateById;
|
||||
private readonly GameCampaignService m_CampaignService;
|
||||
private readonly Dictionary<Guid, Character> m_CharactersById;
|
||||
private readonly GameAuthService m_AuthService;
|
||||
private readonly IDiceRoller m_DiceRoller;
|
||||
|
||||
Reference in New Issue
Block a user