Extract shared game service helpers

This commit is contained in:
2026-04-05 01:05:36 +02:00
parent a2e130abb1
commit 6f9acdc165
14 changed files with 821 additions and 590 deletions

View File

@@ -48,7 +48,7 @@ public sealed class GameAuthService
m_StateStore.UserIdsByUsername[user.UsernameNormalized] = user.Id;
m_PersistenceService.PersistStateLocked();
return ServiceResult<UserSummary>.Success(ToUserSummary(user));
return ServiceResult<UserSummary>.Success(GameDtoMapper.ToUserSummary(user));
}
}
@@ -73,7 +73,7 @@ public sealed class GameAuthService
var session = CreateSession(userId);
m_PersistenceService.PersistStateLocked();
return ServiceResult<(UserSummary User, string SessionToken)>.Success((ToUserSummary(user), session.Token));
return ServiceResult<(UserSummary User, string SessionToken)>.Success((GameDtoMapper.ToUserSummary(user), session.Token));
}
}
@@ -90,8 +90,8 @@ public sealed class GameAuthService
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
return user is null ? null : ToUserSummary(user);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
return user is null ? null : GameDtoMapper.ToUserSummary(user);
}
}
@@ -99,7 +99,7 @@ public sealed class GameAuthService
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
if (user is null)
return ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.");
@@ -115,7 +115,7 @@ public sealed class GameAuthService
campaignId = activeCharacter.CampaignId;
}
return ServiceResult<MeResponse>.Success(new(ToUserSummary(user), user.ActiveCharacterId, campaignId));
return ServiceResult<MeResponse>.Success(new(GameDtoMapper.ToUserSummary(user), user.ActiveCharacterId, campaignId));
}
}
@@ -133,22 +133,6 @@ public sealed class GameAuthService
return session;
}
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 static UserSummary ToUserSummary(UserAccount user)
{
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles));
}
private static string NormalizeUsername(string username)
{
return username.ToUpperInvariant();

View File

@@ -0,0 +1,36 @@
using RpgRoller.Domain;
namespace RpgRoller.Services;
public static class GameAuthorization
{
public static bool HasRole(UserAccount user, string role)
{
return RoleSerializer.HasRole(user.Roles, role);
}
public static bool CanViewCampaign(GameStateStore stateStore, Guid actorUserId, Guid campaignId)
{
if (stateStore.UsersById.TryGetValue(actorUserId, out var user) && HasRole(user, UserRoles.Admin))
return true;
var campaign = stateStore.CampaignsById[campaignId];
if (campaign.GmUserId == actorUserId)
return true;
return stateStore.CharactersById.Values.Any(character =>
character.CampaignId == campaignId &&
character.OwnerUserId == actorUserId);
}
public static bool CanEditCharacter(Guid actorUserId, Character character, Campaign campaign)
{
return character.OwnerUserId == actorUserId || campaign.GmUserId == actorUserId;
}
public static bool CanViewRoll(GameStateStore stateStore, Guid actorUserId, Campaign campaign, RollLogEntry entry)
{
return CanViewCampaign(stateStore, actorUserId, campaign.Id) &&
(entry.Visibility == RollVisibility.Public || entry.RollerUserId == actorUserId || campaign.GmUserId == actorUserId);
}
}

View File

@@ -22,7 +22,7 @@ public sealed class GameCampaignService
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
if (user is null)
return ServiceResult<CampaignSummary>.Failure("unauthorized", "You must be logged in.");
@@ -37,7 +37,7 @@ public sealed class GameCampaignService
m_StateStore.CampaignsById[campaign.Id] = campaign;
m_PersistenceService.PersistStateLocked();
return ServiceResult<CampaignSummary>.Success(ToCampaignSummary(campaign));
return ServiceResult<CampaignSummary>.Success(GameDtoMapper.ToCampaignSummary(m_StateStore, campaign));
}
}
@@ -45,27 +45,14 @@ public sealed class GameCampaignService
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, 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
var results = m_StateStore.CampaignsById.Values
.Where(campaign => GameAuthorization.CanViewCampaign(m_StateStore, user.Id, campaign.Id))
.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCampaignSummary)
.Select(campaign => GameDtoMapper.ToCampaignSummary(m_StateStore, campaign))
.ToArray();
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(results);
@@ -76,13 +63,13 @@ public sealed class GameCampaignService
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, 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))
.Select(GameDtoMapper.ToCampaignOption)
.ToArray();
return ServiceResult<IReadOnlyList<CampaignOption>>.Success(options);
@@ -93,12 +80,12 @@ public sealed class GameCampaignService
{
lock (m_StateStore.Gate)
{
var context = ResolveContextLocked(sessionToken, campaignId);
var context = GameContextResolver.ResolveCampaignContextLocked(m_StateStore, 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 ServiceResult<CampaignRoster>.Success(GameDtoMapper.ToCampaignRoster(m_StateStore, campaign));
}
}
@@ -106,14 +93,14 @@ public sealed class GameCampaignService
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, 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))
if (campaign.GmUserId != user.Id && !GameAuthorization.HasRole(user, UserRoles.Admin))
return ServiceResult<bool>.Failure("forbidden", "Only the campaign owner or admin can delete this campaign.");
DeleteCampaignLocked(campaignId);
@@ -122,75 +109,6 @@ public sealed class GameCampaignService
}
}
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))

View File

@@ -18,7 +18,7 @@ public sealed class GameCharacterService
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
if (user is null)
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
@@ -34,11 +34,11 @@ public sealed class GameCharacterService
};
m_StateStore.CharactersById[character.Id] = character;
AddCharacterStateLocked(character.CampaignId, character.Id);
TouchRosterLocked(character.CampaignId);
m_StateStore.AddCharacterStateLocked(character.CampaignId, character.Id);
m_StateStore.TouchRosterLocked(character.CampaignId);
m_PersistenceService.PersistStateLocked();
return ServiceResult<CharacterSummary>.Success(ToCharacterSummary(character));
return ServiceResult<CharacterSummary>.Success(GameDtoMapper.ToCharacterSummary(m_StateStore, character));
}
}
@@ -49,7 +49,7 @@ public sealed class GameCharacterService
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
if (user is null)
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
@@ -61,7 +61,7 @@ public sealed class GameCharacterService
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
var isOwner = character.OwnerUserId == user.Id;
var isAdmin = RoleSerializer.HasRole(user.Roles, UserRoles.Admin);
var isAdmin = GameAuthorization.HasRole(user, UserRoles.Admin);
var isSourceGm = character.CampaignId.HasValue &&
m_StateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) &&
sourceCampaign.GmUserId == user.Id;
@@ -95,16 +95,16 @@ public sealed class GameCharacterService
if (sourceCampaignId != character.CampaignId)
{
RemoveCharacterStateLocked(sourceCampaignId, character.Id);
AddCharacterStateLocked(character.CampaignId, character.Id);
m_StateStore.RemoveCharacterStateLocked(sourceCampaignId, character.Id);
m_StateStore.AddCharacterStateLocked(character.CampaignId, character.Id);
}
TouchRosterLocked(sourceCampaignId);
m_StateStore.TouchRosterLocked(sourceCampaignId);
if (sourceCampaignId != character.CampaignId)
TouchRosterLocked(character.CampaignId);
m_StateStore.TouchRosterLocked(character.CampaignId);
m_PersistenceService.PersistStateLocked();
return ServiceResult<CharacterSummary>.Success(ToCharacterSummary(character));
return ServiceResult<CharacterSummary>.Success(GameDtoMapper.ToCharacterSummary(m_StateStore, character));
}
}
@@ -112,7 +112,7 @@ public sealed class GameCharacterService
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
@@ -120,7 +120,7 @@ public sealed class GameCharacterService
return ServiceResult<bool>.Failure("character_not_found", "Character was not found.");
var isOwner = character.OwnerUserId == user.Id;
var isAdmin = RoleSerializer.HasRole(user.Roles, UserRoles.Admin);
var isAdmin = GameAuthorization.HasRole(user, UserRoles.Admin);
if (!isOwner && !isAdmin)
return ServiceResult<bool>.Failure("forbidden", "Only the owner or admin can delete this character.");
@@ -134,7 +134,7 @@ public sealed class GameCharacterService
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
@@ -154,69 +154,20 @@ public sealed class GameCharacterService
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in.");
var characters = m_StateStore.CharactersById.Values
.Where(character => character.OwnerUserId == user.Id)
.OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCharacterSummary)
.Select(character => GameDtoMapper.ToCharacterSummary(m_StateStore, character))
.ToArray();
return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters);
}
}
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 void AddCharacterStateLocked(Guid? campaignId, Guid characterId)
{
if (!campaignId.HasValue || !m_StateStore.CampaignsById.ContainsKey(campaignId.Value))
return;
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
state.CharacterVersions[characterId] = 1;
}
private void RemoveCharacterStateLocked(Guid? campaignId, Guid characterId)
{
if (!campaignId.HasValue || !m_StateStore.CampaignStateById.TryGetValue(campaignId.Value, out var state))
return;
state.CharacterVersions.Remove(characterId);
}
private void TouchRosterLocked(Guid? campaignId)
{
if (!campaignId.HasValue || !m_StateStore.CampaignsById.ContainsKey(campaignId.Value))
return;
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
state.TotalVersion += 1;
state.RosterVersion += 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 void DeleteCharacterLocked(Guid characterId)
{
if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character))
@@ -238,20 +189,8 @@ public sealed class GameCharacterService
foreach (var user in m_StateStore.UsersById.Values.Where(account => account.ActiveCharacterId == characterId))
user.ActiveCharacterId = null;
RemoveCharacterStateLocked(campaignId, characterId);
TouchRosterLocked(campaignId);
}
private CharacterSummary ToCharacterSummary(Character character)
{
return new(character.Id, character.Name, character.OwnerUserId, character.CampaignId, ResolveOwnerDisplayName(character.OwnerUserId));
}
private string ResolveOwnerDisplayName(Guid ownerUserId)
{
return m_StateStore.UsersById.TryGetValue(ownerUserId, out var user)
? user.DisplayName
: "Unknown user";
m_StateStore.RemoveCharacterStateLocked(campaignId, characterId);
m_StateStore.TouchRosterLocked(campaignId);
}
private static string NormalizeUsername(string username)

View File

@@ -0,0 +1,48 @@
using RpgRoller.Domain;
namespace RpgRoller.Services;
public static class GameContextResolver
{
public static UserAccount? ResolveUserLocked(GameStateStore stateStore, string sessionToken)
{
if (string.IsNullOrWhiteSpace(sessionToken))
return null;
if (!stateStore.SessionsByToken.TryGetValue(sessionToken, out var session))
return null;
return stateStore.UsersById.GetValueOrDefault(session.UserId);
}
public static ServiceResult<(UserAccount User, Campaign Campaign)> ResolveCampaignContextLocked(GameStateStore stateStore, string sessionToken, Guid campaignId)
{
var user = ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("unauthorized", "You must be logged in.");
if (!stateStore.CampaignsById.TryGetValue(campaignId, out var campaign))
return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("campaign_not_found", "Campaign was not found.");
if (!GameAuthorization.CanViewCampaign(stateStore, 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));
}
public static bool TryResolveCharacterCampaignLocked(GameStateStore stateStore, Character character, out Campaign campaign, out ServiceError? error)
{
campaign = default!;
if (!character.CampaignId.HasValue ||
!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;
}
}

View File

@@ -0,0 +1,151 @@
using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Services;
public static class GameDtoMapper
{
public static UserSummary ToUserSummary(UserAccount user)
{
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles));
}
public static AdminUserSummary ToAdminUserSummary(UserAccount user)
{
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles));
}
public static CampaignOption ToCampaignOption(Campaign campaign)
{
return new(campaign.Id, campaign.Name);
}
public static CampaignSummary ToCampaignSummary(GameStateStore stateStore, Campaign campaign)
{
var gm = stateStore.UsersById[campaign.GmUserId];
var characterCount = 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);
}
public static CampaignRoster ToCampaignRoster(GameStateStore stateStore, Campaign campaign)
{
var gm = stateStore.UsersById[campaign.GmUserId];
var characters = stateStore.CharactersById.Values
.Where(character => character.CampaignId == campaign.Id)
.OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase)
.Select(character => ToCharacterSummary(stateStore, character))
.ToArray();
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), new CampaignGmSummary(gm.Id, gm.DisplayName), characters);
}
public static CharacterSummary ToCharacterSummary(GameStateStore stateStore, Character character)
{
return new(character.Id, character.Name, character.OwnerUserId, character.CampaignId, ResolveOwnerDisplayName(stateStore, character.OwnerUserId, "Unknown user"));
}
public static CharacterSheet ToCharacterSheet(GameStateStore stateStore, Guid characterId)
{
var skillGroups = stateStore.SkillGroupsById.Values
.Where(group => group.CharacterId == characterId)
.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCharacterSheetSkillGroup)
.ToArray();
var skills = stateStore.SkillsById.Values
.Where(skill => skill.CharacterId == characterId)
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCharacterSheetSkill)
.ToArray();
return new(characterId, skillGroups, skills);
}
public static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup)
{
return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.FumbleRange);
}
public static SkillSummary ToSkillSummary(Skill skill)
{
return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange);
}
public 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);
}
public static CampaignLogEntry ToCampaignLogEntry(RollLogEntry entry, string characterName, string skillName, string rollerDisplayName, IReadOnlyList<RollDieResult> dice)
{
return new(
entry.Id,
entry.CampaignId,
entry.CharacterId,
characterName,
entry.SkillId,
skillName,
entry.RollerUserId,
rollerDisplayName,
entry.Visibility == RollVisibility.Public ? "public" : "private",
entry.Result,
entry.Breakdown,
dice,
entry.TimestampUtc);
}
public static CampaignLogListEntry ToCampaignLogListEntry(
RollLogEntry entry,
string characterName,
string skillName,
string rollerLabel,
string visibilityLabel,
string visibilityStyle,
string summaryText,
string[]? eventBadges)
{
return new(
entry.Id,
characterName,
skillName,
rollerLabel,
visibilityLabel,
visibilityStyle,
entry.Result,
summaryText,
eventBadges,
entry.TimestampUtc);
}
public static CampaignRollDetail ToCampaignRollDetail(RollLogEntry entry, RollDieResult[] dice)
{
return new(entry.Id, entry.Breakdown, dice);
}
public static CampaignStateSnapshot ToCampaignStateSnapshot(GameStateStore stateStore, Guid campaignId)
{
var state = stateStore.GetOrCreateCampaignStateLocked(campaignId);
var characterVersions = state.CharacterVersions
.OrderBy(version => version.Key)
.Select(version => new CharacterStateVersion(version.Key, version.Value))
.ToArray();
return new CampaignStateSnapshot(campaignId, state.TotalVersion, state.RosterVersion, state.LogVersion, characterVersions);
}
public static string ResolveOwnerDisplayName(GameStateStore stateStore, Guid ownerUserId, string fallback)
{
return stateStore.UsersById.TryGetValue(ownerUserId, out var user) && !string.IsNullOrWhiteSpace(user.DisplayName)
? user.DisplayName
: fallback;
}
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);
}
}

View File

@@ -17,7 +17,7 @@ public sealed class GameRollService
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
if (user is null)
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in.");
@@ -25,10 +25,10 @@ public sealed class GameRollService
return ServiceResult<RollResult>.Failure("skill_not_found", "Skill was not found.");
var character = m_StateStore.CharactersById[skill.CharacterId];
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError))
return ServiceResult<RollResult>.Failure(campaignError!.Code, campaignError.Message);
if (!CanEditCharacterLocked(user.Id, character, campaign))
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
return ServiceResult<RollResult>.Failure("forbidden", "Only the owner or GM can roll this skill.");
var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, skill.DiceRollDefinition);
@@ -48,17 +48,17 @@ public sealed class GameRollService
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
if (user is null)
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character))
return ServiceResult<RollResult>.Failure("character_not_found", "Character was not found.");
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError))
return ServiceResult<RollResult>.Failure(campaignError!.Code, campaignError.Message);
if (!CanEditCharacterLocked(user.Id, character, campaign))
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
return ServiceResult<RollResult>.Failure("forbidden", "Only the owner or GM can make a custom roll for this character.");
var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, expression);
@@ -79,7 +79,7 @@ public sealed class GameRollService
{
lock (m_StateStore.Gate)
{
var context = ResolveContextLocked(sessionToken, campaignId);
var context = GameContextResolver.ResolveCampaignContextLocked(m_StateStore, sessionToken, campaignId);
if (!context.Succeeded)
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Failure(context.Error!.Code, context.Error.Message);
@@ -97,7 +97,7 @@ public sealed class GameRollService
{
lock (m_StateStore.Gate)
{
var context = ResolveContextLocked(sessionToken, campaignId);
var context = GameContextResolver.ResolveCampaignContextLocked(m_StateStore, sessionToken, campaignId);
if (!context.Succeeded)
return ServiceResult<CampaignLogPage>.Failure(context.Error!.Code, context.Error.Message);
@@ -137,7 +137,7 @@ public sealed class GameRollService
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
if (user is null)
return ServiceResult<CampaignRollDetail>.Failure("unauthorized", "You must be logged in.");
@@ -145,10 +145,10 @@ public sealed class GameRollService
if (entry is null)
return ServiceResult<CampaignRollDetail>.Failure("roll_not_found", "Roll was not found.");
if (!m_StateStore.CampaignsById.TryGetValue(entry.CampaignId, out var campaign) || !CanViewRollLocked(user, campaign, entry))
if (!m_StateStore.CampaignsById.TryGetValue(entry.CampaignId, out var campaign) || !GameAuthorization.CanViewRoll(m_StateStore, user.Id, campaign, entry))
return ServiceResult<CampaignRollDetail>.Failure("roll_not_found", "Roll was not found.");
return ServiceResult<CampaignRollDetail>.Success(new CampaignRollDetail(entry.Id, entry.Breakdown, DeserializeDice(entry.Dice).ToArray()));
return ServiceResult<CampaignRollDetail>.Success(GameDtoMapper.ToCampaignRollDetail(entry, DeserializeDice(entry.Dice).ToArray()));
}
}
@@ -156,11 +156,11 @@ public sealed class GameRollService
{
lock (m_StateStore.Gate)
{
var context = ResolveContextLocked(sessionToken, campaignId);
var context = GameContextResolver.ResolveCampaignContextLocked(m_StateStore, sessionToken, campaignId);
if (!context.Succeeded)
return ServiceResult<CampaignStateSnapshot>.Failure(context.Error!.Code, context.Error.Message);
return ServiceResult<CampaignStateSnapshot>.Success(ToCampaignStateSnapshot(context.Value!.Campaign));
return ServiceResult<CampaignStateSnapshot>.Success(GameDtoMapper.ToCampaignStateSnapshot(m_StateStore, context.Value!.Campaign.Id));
}
}
@@ -422,10 +422,10 @@ public sealed class GameRollService
};
m_StateStore.RollLog.Add(entry);
TouchLogLocked(campaign.Id);
m_StateStore.TouchLogLocked(campaign.Id);
m_PersistenceService.PersistStateLocked();
return ServiceResult<RollResult>.Success(ToRollResult(entry, roll.Dice));
return ServiceResult<RollResult>.Success(GameDtoMapper.ToRollResult(entry, roll.Dice));
}
private static string FormatLoggedBreakdown(Guid skillId, string canonicalExpression, string breakdown)
@@ -435,32 +435,6 @@ public sealed class GameRollService
: breakdown;
}
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 CampaignStateSnapshot ToCampaignStateSnapshot(Campaign campaign)
{
var state = GetOrCreateCampaignStateLocked(campaign.Id);
var characterVersions = state.CharacterVersions
.OrderBy(version => version.Key)
.Select(version => new CharacterStateVersion(version.Key, version.Value))
.ToArray();
return new CampaignStateSnapshot(campaign.Id, state.TotalVersion, state.RosterVersion, state.LogVersion, characterVersions);
}
private IEnumerable<RollLogEntry> GetVisibleCampaignLogEntriesLocked(UserAccount user, Campaign campaign)
{
return m_StateStore.RollLog
@@ -470,32 +444,14 @@ public sealed class GameRollService
.ThenBy(r => r.Id);
}
private static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList<RollDieResult> dice)
{
return new(entry.Id, entry.CampaignId, entry.CharacterId, entry.SkillId, entry.RollerUserId, entry.Visibility == RollVisibility.Public ? "public" : "private", entry.Result, entry.Breakdown, dice, entry.TimestampUtc);
}
private CampaignLogEntry ToLogEntry(RollLogEntry entry)
{
var dice = DeserializeDice(entry.Dice);
var characterName = m_StateStore.CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character";
var skillName = ResolveLoggedSkillName(entry);
var rollerDisplayName = ResolveOwnerDisplayName(entry.RollerUserId);
var rollerDisplayName = GameDtoMapper.ResolveOwnerDisplayName(m_StateStore, entry.RollerUserId, "Unknown owner");
return new(
entry.Id,
entry.CampaignId,
entry.CharacterId,
characterName,
entry.SkillId,
skillName,
entry.RollerUserId,
rollerDisplayName,
entry.Visibility == RollVisibility.Public ? "public" : "private",
entry.Result,
entry.Breakdown,
dice,
entry.TimestampUtc);
return GameDtoMapper.ToCampaignLogEntry(entry, characterName, skillName, rollerDisplayName, dice);
}
private CampaignLogListEntry ToLogListEntry(UserAccount user, Campaign campaign, RollLogEntry entry)
@@ -506,17 +462,15 @@ public sealed class GameRollService
var loggedExpression = ResolveLoggedExpression(entry);
var eventBadges = BuildCompactLogEventBadges(campaign, loggedExpression, dice);
return new(
entry.Id,
return GameDtoMapper.ToCampaignLogListEntry(
entry,
characterName,
skillName,
ResolveLogRollerLabel(user, campaign, entry),
ResolveLogVisibilityLabel(user, campaign, entry),
ResolveLogVisibilityStyle(user, campaign, entry),
entry.Result,
BuildCompactLogSummary(dice),
eventBadges,
entry.TimestampUtc);
eventBadges);
}
private static string SerializeDice(IReadOnlyList<RollDieResult> dice)
@@ -648,12 +602,6 @@ public sealed class GameRollService
parsedExpression.Value.Sides == 20;
}
private bool CanViewRollLocked(UserAccount user, Campaign campaign, RollLogEntry entry)
{
return CanViewCampaignLocked(user.Id, campaign.Id) &&
(entry.Visibility == RollVisibility.Public || entry.RollerUserId == user.Id || campaign.GmUserId == user.Id);
}
private string ResolveLogRollerLabel(UserAccount user, Campaign campaign, RollLogEntry entry)
{
if (entry.RollerUserId == user.Id)
@@ -662,7 +610,7 @@ public sealed class GameRollService
if (entry.RollerUserId == campaign.GmUserId)
return "GM";
return ResolveOwnerDisplayName(entry.RollerUserId);
return GameDtoMapper.ResolveOwnerDisplayName(m_StateStore, entry.RollerUserId, "Unknown owner");
}
private static string ResolveLogVisibilityLabel(UserAccount user, Campaign campaign, RollLogEntry entry)
@@ -687,13 +635,6 @@ public sealed class GameRollService
return campaign.GmUserId == user.Id ? "private-gm" : "private-generic";
}
private string ResolveOwnerDisplayName(Guid ownerUserId)
{
return m_StateStore.UsersById.TryGetValue(ownerUserId, out var owner) && !string.IsNullOrWhiteSpace(owner.DisplayName)
? owner.DisplayName
: "Unknown owner";
}
private static IReadOnlyList<RollDieResult> DeserializeDice(string serializedDice)
{
if (string.IsNullOrWhiteSpace(serializedDice))
@@ -709,69 +650,6 @@ public sealed class GameRollService
}
}
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 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 void TouchLogLocked(Guid? campaignId)
{
if (!campaignId.HasValue || !m_StateStore.CampaignsById.ContainsKey(campaignId.Value))
return;
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
state.TotalVersion += 1;
state.LogVersion += 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 static int NormalizeCampaignLogPageSize(int? limit)
{
if (!limit.HasValue)

View File

@@ -11,7 +11,6 @@ public sealed class GameService : IGameService
public GameService(IDbContextFactory<RpgRollerDbContext> dbContextFactory, IPasswordHasher<UserAccount> passwordHasher, IDiceRoller diceRoller)
{
m_StateStore = new();
m_Gate = m_StateStore.Gate;
m_PersistenceService = new(dbContextFactory, m_StateStore);
m_AuthService = new(m_StateStore, passwordHasher, m_PersistenceService);
m_CampaignService = new(m_StateStore, m_PersistenceService);
@@ -19,7 +18,9 @@ public sealed class GameService : IGameService
m_RollService = new(m_StateStore, m_PersistenceService, diceRoller);
m_SkillService = new(m_StateStore, m_PersistenceService);
m_UserAdministrationService = new(m_StateStore, m_PersistenceService);
LoadStateFromDatabase();
m_PersistenceService.LoadStateFromDatabase();
lock (m_StateStore.Gate)
m_StateStore.RebuildCampaignStateLocked();
}
public IReadOnlyList<RulesetDefinition> GetRulesets()
@@ -187,48 +188,9 @@ public sealed class GameService : IGameService
return m_RollService.GetCampaignStateSnapshot(sessionToken, campaignId);
}
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 void AddCharacterStateLocked(Guid? campaignId, Guid characterId)
{
if (!campaignId.HasValue || !m_StateStore.CampaignsById.ContainsKey(campaignId.Value))
return;
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
state.CharacterVersions[characterId] = 1;
}
private void RebuildCampaignStateLocked()
{
m_StateStore.CampaignStateById.Clear();
foreach (var campaignId in m_StateStore.CampaignsById.Keys)
m_StateStore.CampaignStateById[campaignId] = new GameCampaignStateTracker();
foreach (var character in m_StateStore.CharactersById.Values.Where(character => character.CampaignId.HasValue))
AddCharacterStateLocked(character.CampaignId, character.Id);
}
private void LoadStateFromDatabase()
{
m_PersistenceService.LoadStateFromDatabase();
lock (m_Gate)
RebuildCampaignStateLocked();
}
private readonly GameCampaignService m_CampaignService;
private readonly GameCharacterService m_CharacterService;
private readonly GameAuthService m_AuthService;
private readonly object m_Gate;
private readonly GamePersistenceService m_PersistenceService;
private readonly GameRollService m_RollService;
private readonly GameSkillService m_SkillService;

View File

@@ -18,17 +18,17 @@ public sealed class GameSkillService
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, 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))
if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError))
return ServiceResult<SkillGroupSummary>.Failure(campaignError!.Code, campaignError.Message);
if (!CanEditCharacterLocked(user.Id, character, campaign))
if (!GameAuthorization.CanEditCharacter(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);
@@ -47,10 +47,10 @@ public sealed class GameSkillService
};
m_StateStore.SkillGroupsById[group.Id] = group;
TouchCharacterLocked(campaign.Id, character.Id);
m_StateStore.TouchCharacterLocked(campaign.Id, character.Id);
m_PersistenceService.PersistStateLocked();
return ServiceResult<SkillGroupSummary>.Success(ToSkillGroupSummary(group));
return ServiceResult<SkillGroupSummary>.Success(GameDtoMapper.ToSkillGroupSummary(group));
}
}
@@ -61,7 +61,7 @@ public sealed class GameSkillService
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
if (user is null)
return ServiceResult<SkillGroupSummary>.Failure("unauthorized", "You must be logged in.");
@@ -69,10 +69,10 @@ public sealed class GameSkillService
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))
if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError))
return ServiceResult<SkillGroupSummary>.Failure(campaignError!.Code, campaignError.Message);
if (!CanEditCharacterLocked(user.Id, character, campaign))
if (!GameAuthorization.CanEditCharacter(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);
@@ -84,10 +84,10 @@ public sealed class GameSkillService
group.WildDice = prototypeValidation.Value.WildDice;
group.AllowFumble = prototypeValidation.Value.AllowFumble;
group.FumbleRange = prototypeValidation.Value.FumbleRange;
TouchCharacterLocked(campaign.Id, character.Id);
m_StateStore.TouchCharacterLocked(campaign.Id, character.Id);
m_PersistenceService.PersistStateLocked();
return ServiceResult<SkillGroupSummary>.Success(ToSkillGroupSummary(group));
return ServiceResult<SkillGroupSummary>.Success(GameDtoMapper.ToSkillGroupSummary(group));
}
}
@@ -95,7 +95,7 @@ public sealed class GameSkillService
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
@@ -103,17 +103,17 @@ public sealed class GameSkillService
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))
if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError))
return ServiceResult<bool>.Failure(campaignError!.Code, campaignError.Message);
if (!CanEditCharacterLocked(user.Id, character, campaign))
if (!GameAuthorization.CanEditCharacter(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_StateStore.TouchCharacterLocked(campaign.Id, character.Id);
m_PersistenceService.PersistStateLocked();
return ServiceResult<bool>.Success(true);
@@ -127,17 +127,17 @@ public sealed class GameSkillService
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, 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))
if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError))
return ServiceResult<SkillSummary>.Failure(campaignError!.Code, campaignError.Message);
if (!CanEditCharacterLocked(user.Id, character, campaign))
if (!GameAuthorization.CanEditCharacter(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);
@@ -161,10 +161,10 @@ public sealed class GameSkillService
};
m_StateStore.SkillsById[skill.Id] = skill;
TouchCharacterLocked(campaign.Id, character.Id);
m_StateStore.TouchCharacterLocked(campaign.Id, character.Id);
m_PersistenceService.PersistStateLocked();
return ServiceResult<SkillSummary>.Success(ToSkillSummary(skill));
return ServiceResult<SkillSummary>.Success(GameDtoMapper.ToSkillSummary(skill));
}
}
@@ -175,7 +175,7 @@ public sealed class GameSkillService
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
if (user is null)
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
@@ -183,10 +183,10 @@ public sealed class GameSkillService
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))
if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError))
return ServiceResult<SkillSummary>.Failure(campaignError!.Code, campaignError.Message);
if (!CanEditCharacterLocked(user.Id, character, campaign))
if (!GameAuthorization.CanEditCharacter(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);
@@ -203,10 +203,10 @@ public sealed class GameSkillService
skill.AllowFumble = skillValidation.Value.AllowFumble;
skill.FumbleRange = skillValidation.Value.FumbleRange;
skill.SkillGroupId = resolvedSkillGroupId.Value;
TouchCharacterLocked(campaign.Id, character.Id);
m_StateStore.TouchCharacterLocked(campaign.Id, character.Id);
m_PersistenceService.PersistStateLocked();
return ServiceResult<SkillSummary>.Success(ToSkillSummary(skill));
return ServiceResult<SkillSummary>.Success(GameDtoMapper.ToSkillSummary(skill));
}
}
@@ -214,7 +214,7 @@ public sealed class GameSkillService
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
@@ -222,14 +222,14 @@ public sealed class GameSkillService
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))
if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError))
return ServiceResult<bool>.Failure(campaignError!.Code, campaignError.Message);
if (!CanEditCharacterLocked(user.Id, character, campaign))
if (!GameAuthorization.CanEditCharacter(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_StateStore.TouchCharacterLocked(campaign.Id, character.Id);
m_PersistenceService.PersistStateLocked();
return ServiceResult<bool>.Success(true);
@@ -240,65 +240,23 @@ public sealed class GameSkillService
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, 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))
if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError))
return ServiceResult<CharacterSheet>.Failure(campaignError!.Code, campaignError.Message);
if (!CanViewCampaignLocked(user.Id, campaign.Id))
if (!GameAuthorization.CanViewCampaign(m_StateStore, 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 ServiceResult<CharacterSheet>.Success(GameDtoMapper.ToCharacterSheet(m_StateStore, 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)
@@ -313,63 +271,6 @@ public sealed class GameSkillService
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;
}

View File

@@ -14,6 +14,75 @@ public sealed class GameStateStore
public Dictionary<Guid, Skill> SkillsById { get; } = [];
public Dictionary<string, Guid> UserIdsByUsername { get; } = new(StringComparer.Ordinal);
public Dictionary<Guid, UserAccount> UsersById { get; } = [];
public GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId)
{
if (!CampaignStateById.TryGetValue(campaignId, out var state))
{
state = new GameCampaignStateTracker();
CampaignStateById[campaignId] = state;
}
return state;
}
public void RebuildCampaignStateLocked()
{
CampaignStateById.Clear();
foreach (var campaignId in CampaignsById.Keys)
CampaignStateById[campaignId] = new GameCampaignStateTracker();
foreach (var character in CharactersById.Values.Where(character => character.CampaignId.HasValue))
AddCharacterStateLocked(character.CampaignId, character.Id);
}
public void AddCharacterStateLocked(Guid? campaignId, Guid characterId)
{
if (!campaignId.HasValue || !CampaignsById.ContainsKey(campaignId.Value))
return;
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
state.CharacterVersions[characterId] = 1;
}
public void RemoveCharacterStateLocked(Guid? campaignId, Guid characterId)
{
if (!campaignId.HasValue || !CampaignStateById.TryGetValue(campaignId.Value, out var state))
return;
state.CharacterVersions.Remove(characterId);
}
public void TouchRosterLocked(Guid? campaignId)
{
if (!campaignId.HasValue || !CampaignsById.ContainsKey(campaignId.Value))
return;
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
state.TotalVersion += 1;
state.RosterVersion += 1;
}
public void TouchCharacterLocked(Guid? campaignId, Guid characterId)
{
if (!campaignId.HasValue || !CampaignsById.ContainsKey(campaignId.Value))
return;
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
state.TotalVersion += 1;
state.CharacterVersions[characterId] = state.CharacterVersions.GetValueOrDefault(characterId, 1) + 1;
}
public void TouchLogLocked(Guid? campaignId)
{
if (!campaignId.HasValue || !CampaignsById.ContainsKey(campaignId.Value))
return;
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
state.TotalVersion += 1;
state.LogVersion += 1;
}
}
public sealed class GameCampaignStateTracker

View File

@@ -15,7 +15,7 @@ public sealed class GameUserAdministrationService
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<string>>.Failure("unauthorized", "You must be logged in.");
@@ -32,16 +32,16 @@ public sealed class GameUserAdministrationService
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("unauthorized", "You must be logged in.");
if (!UserHasRoleLocked(user, UserRoles.Admin))
if (!GameAuthorization.HasRole(user, UserRoles.Admin))
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("forbidden", "Admin role is required.");
var users = m_StateStore.UsersById.Values
.OrderBy(account => account.Username, StringComparer.OrdinalIgnoreCase)
.Select(ToAdminUserSummary)
.Select(GameDtoMapper.ToAdminUserSummary)
.ToArray();
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Success(users);
@@ -52,11 +52,11 @@ public sealed class GameUserAdministrationService
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
if (user is null)
return ServiceResult<AdminUserSummary>.Failure("unauthorized", "You must be logged in.");
if (!UserHasRoleLocked(user, UserRoles.Admin))
if (!GameAuthorization.HasRole(user, UserRoles.Admin))
return ServiceResult<AdminUserSummary>.Failure("forbidden", "Admin role is required.");
if (!m_StateStore.UsersById.TryGetValue(userId, out var targetUser))
@@ -71,7 +71,7 @@ public sealed class GameUserAdministrationService
targetUser.Roles = RoleSerializer.Serialize(normalizedRoles);
m_PersistenceService.PersistStateLocked();
return ServiceResult<AdminUserSummary>.Success(ToAdminUserSummary(targetUser));
return ServiceResult<AdminUserSummary>.Success(GameDtoMapper.ToAdminUserSummary(targetUser));
}
}
@@ -79,11 +79,11 @@ public sealed class GameUserAdministrationService
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
if (!UserHasRoleLocked(user, UserRoles.Admin))
if (!GameAuthorization.HasRole(user, UserRoles.Admin))
return ServiceResult<bool>.Failure("forbidden", "Admin role is required.");
if (user.Id == userId)
@@ -129,27 +129,6 @@ public sealed class GameUserAdministrationService
}
}
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 static bool UserHasRoleLocked(UserAccount user, string role)
{
return RoleSerializer.HasRole(user.Roles, role);
}
private static AdminUserSummary ToAdminUserSummary(UserAccount user)
{
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles));
}
private void DeleteCampaignLocked(Guid campaignId)
{
if (!m_StateStore.CampaignsById.Remove(campaignId))
@@ -193,37 +172,8 @@ public sealed class GameUserAdministrationService
foreach (var account in m_StateStore.UsersById.Values.Where(account => account.ActiveCharacterId == characterId))
account.ActiveCharacterId = null;
RemoveCharacterStateLocked(campaignId, characterId);
TouchRosterLocked(campaignId);
}
private void RemoveCharacterStateLocked(Guid? campaignId, Guid characterId)
{
if (!campaignId.HasValue || !m_StateStore.CampaignStateById.TryGetValue(campaignId.Value, out var state))
return;
state.CharacterVersions.Remove(characterId);
}
private void TouchRosterLocked(Guid? campaignId)
{
if (!campaignId.HasValue || !m_StateStore.CampaignsById.ContainsKey(campaignId.Value))
return;
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
state.TotalVersion += 1;
state.RosterVersion += 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;
m_StateStore.RemoveCharacterStateLocked(campaignId, characterId);
m_StateStore.TouchRosterLocked(campaignId);
}
private readonly GamePersistenceService m_PersistenceService;