Extract game campaign service

This commit is contained in:
2026-04-04 23:23:41 +02:00
parent a9558a16fc
commit 951ce9f1fe
3 changed files with 217 additions and 117 deletions

View File

@@ -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/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/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/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: Frontend:

View 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;
}

View File

@@ -24,6 +24,7 @@ public sealed class GameService : IGameService
m_UsersById = m_StateStore.UsersById; m_UsersById = m_StateStore.UsersById;
m_PersistenceService = new(dbContextFactory, m_StateStore); m_PersistenceService = new(dbContextFactory, m_StateStore);
m_AuthService = new(m_StateStore, passwordHasher, m_PersistenceService); m_AuthService = new(m_StateStore, passwordHasher, m_PersistenceService);
m_CampaignService = new(m_StateStore, m_PersistenceService);
m_DiceRoller = diceRoller; m_DiceRoller = diceRoller;
LoadStateFromDatabase(); LoadStateFromDatabase();
} }
@@ -60,110 +61,27 @@ public sealed class GameService : IGameService
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId) public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
{ {
if (string.IsNullOrWhiteSpace(name)) return m_CampaignService.CreateCampaign(sessionToken, name, rulesetId);
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) public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken)
{ {
lock (m_Gate) return m_CampaignService.GetCampaigns(sessionToken);
{
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) public ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken)
{ {
lock (m_Gate) return m_CampaignService.GetCharacterCampaignOptions(sessionToken);
{
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) public ServiceResult<CampaignRoster> GetCampaign(string sessionToken, Guid campaignId)
{ {
lock (m_Gate) return m_CampaignService.GetCampaign(sessionToken, campaignId);
{
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) public ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId)
{ {
lock (m_Gate) return m_CampaignService.DeleteCampaign(sessionToken, campaignId);
{
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) 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)); 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) private CharacterSheet ToCharacterSheet(Guid characterId)
{ {
var skillGroups = m_SkillGroupsById.Values var skillGroups = m_SkillGroupsById.Values
@@ -1199,11 +1093,6 @@ public sealed class GameService : IGameService
.ThenBy(r => r.Id); .ThenBy(r => r.Id);
} }
private static CampaignGmSummary ToCampaignGmSummary(UserAccount user)
{
return new(user.Id, user.DisplayName);
}
private static CharacterSheetSkillGroup ToCharacterSheetSkillGroup(SkillGroup skillGroup) private static CharacterSheetSkillGroup ToCharacterSheetSkillGroup(SkillGroup skillGroup)
{ {
return new(skillGroup.Id, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.FumbleRange); 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 static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions();
private readonly Dictionary<Guid, Campaign> m_CampaignsById; private readonly Dictionary<Guid, Campaign> m_CampaignsById;
private readonly Dictionary<Guid, GameCampaignStateTracker> m_CampaignStateById; private readonly Dictionary<Guid, GameCampaignStateTracker> m_CampaignStateById;
private readonly GameCampaignService m_CampaignService;
private readonly Dictionary<Guid, Character> m_CharactersById; private readonly Dictionary<Guid, Character> m_CharactersById;
private readonly GameAuthService m_AuthService; private readonly GameAuthService m_AuthService;
private readonly IDiceRoller m_DiceRoller; private readonly IDiceRoller m_DiceRoller;