Extract game state persistence

This commit is contained in:
2026-04-04 23:17:58 +02:00
parent fa5bad23a7
commit 8961c75305
6 changed files with 325 additions and 195 deletions

View File

@@ -21,6 +21,7 @@ Backend:
- `RpgRoller/Api/`: endpoint mapping modules and auth/session filter helpers - `RpgRoller/Api/`: endpoint mapping modules and auth/session filter helpers
- `RpgRoller/Services/`: game workflows with explicit method parameters (no API DTO dependencies) - `RpgRoller/Services/`: game workflows with explicit method parameters (no API DTO dependencies)
- `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`
Frontend: Frontend:

View File

@@ -0,0 +1,62 @@
namespace RpgRoller.Tests;
public sealed class ServiceStateInfrastructureTests
{
[Fact]
public void GameStateStore_StartsWithEmptyMutableCollections()
{
var store = new GameStateStore();
Assert.NotNull(store.Gate);
Assert.Empty(store.UsersById);
Assert.Empty(store.UserIdsByUsername);
Assert.Empty(store.SessionsByToken);
Assert.Empty(store.CampaignsById);
Assert.Empty(store.CampaignStateById);
Assert.Empty(store.CharactersById);
Assert.Empty(store.SkillGroupsById);
Assert.Empty(store.SkillsById);
Assert.Empty(store.RollLog);
}
[Fact]
public void GameStateCloneFactory_ProducesDetachedCopies()
{
var user = new UserAccount
{
Id = Guid.NewGuid(),
Username = "user",
UsernameNormalized = "USER",
PasswordHash = "hash",
DisplayName = "User",
Roles = "admin",
ActiveCharacterId = Guid.NewGuid()
};
var session = new UserSession { Token = "token", UserId = user.Id, CreatedAtUtc = DateTimeOffset.UtcNow };
var campaign = new Campaign { Id = Guid.NewGuid(), GmUserId = user.Id, Name = "Main", Ruleset = RulesetKind.D6, Version = 3 };
var character = new Character { Id = Guid.NewGuid(), OwnerUserId = user.Id, CampaignId = campaign.Id, Name = "Hero" };
var skillGroup = new SkillGroup { Id = Guid.NewGuid(), CharacterId = character.Id, Name = "Group", DiceRollDefinition = "2D+1", WildDice = 1, AllowFumble = true, FumbleRange = null };
var skill = new Skill { Id = Guid.NewGuid(), CharacterId = character.Id, SkillGroupId = skillGroup.Id, Name = "Skill", DiceRollDefinition = "2D+2", WildDice = 1, AllowFumble = true, FumbleRange = null };
var logEntry = new RollLogEntry
{
Id = Guid.NewGuid(),
CampaignId = campaign.Id,
CharacterId = character.Id,
SkillId = skill.Id,
RollerUserId = user.Id,
Visibility = RollVisibility.Public,
Result = 12,
Breakdown = "6 + 6 = 12",
Dice = "[]",
TimestampUtc = DateTimeOffset.UtcNow
};
Assert.NotSame(user, GameStateCloneFactory.CloneUser(user));
Assert.NotSame(session, GameStateCloneFactory.CloneSession(session));
Assert.NotSame(campaign, GameStateCloneFactory.CloneCampaign(campaign));
Assert.NotSame(character, GameStateCloneFactory.CloneCharacter(character));
Assert.NotSame(skillGroup, GameStateCloneFactory.CloneSkillGroup(skillGroup));
Assert.NotSame(skill, GameStateCloneFactory.CloneSkill(skill));
Assert.NotSame(logEntry, GameStateCloneFactory.CloneRollLogEntry(logEntry));
}
}

View File

@@ -0,0 +1,109 @@
using Microsoft.EntityFrameworkCore;
using RpgRoller.Data;
using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class GamePersistenceService
{
public GamePersistenceService(IDbContextFactory<RpgRollerDbContext> dbContextFactory, GameStateStore stateStore)
{
m_DbContextFactory = dbContextFactory;
m_StateStore = stateStore;
}
public void LoadStateFromDatabase()
{
using var db = m_DbContextFactory.CreateDbContext();
var users = db.Users.AsNoTracking().ToList();
var sessions = db.Sessions.AsNoTracking().ToList();
var campaigns = db.Campaigns.AsNoTracking().ToList();
var characters = db.Characters.AsNoTracking().ToList();
var skillGroups = db.SkillGroups.AsNoTracking().ToList();
var skills = db.Skills.AsNoTracking().ToList();
var logEntries = db.RollLogEntries.AsNoTracking().ToList().OrderBy(x => x.TimestampUtc).ThenBy(x => x.Id).ToList();
lock (m_StateStore.Gate)
{
m_StateStore.UsersById.Clear();
m_StateStore.UserIdsByUsername.Clear();
m_StateStore.SessionsByToken.Clear();
m_StateStore.CampaignsById.Clear();
m_StateStore.CharactersById.Clear();
m_StateStore.SkillGroupsById.Clear();
m_StateStore.SkillsById.Clear();
m_StateStore.RollLog.Clear();
foreach (var user in users)
{
var normalizedUsername = string.IsNullOrWhiteSpace(user.UsernameNormalized) ? NormalizeUsername(user.Username) : user.UsernameNormalized;
var storedUser = new UserAccount
{
Id = user.Id,
Username = user.Username,
UsernameNormalized = normalizedUsername,
PasswordHash = user.PasswordHash,
DisplayName = user.DisplayName,
Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : RoleSerializer.Serialize(RoleSerializer.Parse(user.Roles)),
ActiveCharacterId = user.ActiveCharacterId
};
m_StateStore.UsersById[storedUser.Id] = storedUser;
m_StateStore.UserIdsByUsername[normalizedUsername] = storedUser.Id;
}
foreach (var session in sessions)
{
if (m_StateStore.UsersById.ContainsKey(session.UserId))
m_StateStore.SessionsByToken[session.Token] = GameStateCloneFactory.CloneSession(session);
}
foreach (var campaign in campaigns)
m_StateStore.CampaignsById[campaign.Id] = GameStateCloneFactory.CloneCampaign(campaign);
foreach (var character in characters)
m_StateStore.CharactersById[character.Id] = GameStateCloneFactory.CloneCharacter(character);
foreach (var skillGroup in skillGroups)
m_StateStore.SkillGroupsById[skillGroup.Id] = GameStateCloneFactory.CloneSkillGroup(skillGroup);
foreach (var skill in skills)
m_StateStore.SkillsById[skill.Id] = GameStateCloneFactory.CloneSkill(skill);
m_StateStore.RollLog.AddRange(logEntries.Select(GameStateCloneFactory.CloneRollLogEntry));
}
}
public void PersistStateLocked()
{
using var db = m_DbContextFactory.CreateDbContext();
using var transaction = db.Database.BeginTransaction();
db.RollLogEntries.ExecuteDelete();
db.Skills.ExecuteDelete();
db.SkillGroups.ExecuteDelete();
db.Characters.ExecuteDelete();
db.Campaigns.ExecuteDelete();
db.Sessions.ExecuteDelete();
db.Users.ExecuteDelete();
db.Users.AddRange(m_StateStore.UsersById.Values.Select(GameStateCloneFactory.CloneUser));
db.Sessions.AddRange(m_StateStore.SessionsByToken.Values.Select(GameStateCloneFactory.CloneSession));
db.Campaigns.AddRange(m_StateStore.CampaignsById.Values.Select(GameStateCloneFactory.CloneCampaign));
db.Characters.AddRange(m_StateStore.CharactersById.Values.Select(GameStateCloneFactory.CloneCharacter));
db.SkillGroups.AddRange(m_StateStore.SkillGroupsById.Values.Select(GameStateCloneFactory.CloneSkillGroup));
db.Skills.AddRange(m_StateStore.SkillsById.Values.Select(GameStateCloneFactory.CloneSkill));
db.RollLogEntries.AddRange(m_StateStore.RollLog.Select(GameStateCloneFactory.CloneRollLogEntry));
db.SaveChanges();
transaction.Commit();
}
private static string NormalizeUsername(string username)
{
return username.ToUpperInvariant();
}
private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory;
private readonly GameStateStore m_StateStore;
}

View File

@@ -11,7 +11,18 @@ public sealed class GameService : IGameService
{ {
public GameService(IDbContextFactory<RpgRollerDbContext> dbContextFactory, IPasswordHasher<UserAccount> passwordHasher, IDiceRoller diceRoller) public GameService(IDbContextFactory<RpgRollerDbContext> dbContextFactory, IPasswordHasher<UserAccount> passwordHasher, IDiceRoller diceRoller)
{ {
m_DbContextFactory = dbContextFactory; m_StateStore = new();
m_CampaignsById = m_StateStore.CampaignsById;
m_CampaignStateById = m_StateStore.CampaignStateById;
m_CharactersById = m_StateStore.CharactersById;
m_Gate = m_StateStore.Gate;
m_RollLog = m_StateStore.RollLog;
m_SessionsByToken = m_StateStore.SessionsByToken;
m_SkillGroupsById = m_StateStore.SkillGroupsById;
m_SkillsById = m_StateStore.SkillsById;
m_UserIdsByUsername = m_StateStore.UserIdsByUsername;
m_UsersById = m_StateStore.UsersById;
m_PersistenceService = new(dbContextFactory, m_StateStore);
m_PasswordHasher = passwordHasher; m_PasswordHasher = passwordHasher;
m_DiceRoller = diceRoller; m_DiceRoller = diceRoller;
LoadStateFromDatabase(); LoadStateFromDatabase();
@@ -1652,11 +1663,11 @@ public sealed class GameService : IGameService
return m_UsersById.GetValueOrDefault(session.UserId); return m_UsersById.GetValueOrDefault(session.UserId);
} }
private CampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId) private GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId)
{ {
if (!m_CampaignStateById.TryGetValue(campaignId, out var state)) if (!m_CampaignStateById.TryGetValue(campaignId, out var state))
{ {
state = new CampaignStateTracker(); state = new GameCampaignStateTracker();
m_CampaignStateById[campaignId] = state; m_CampaignStateById[campaignId] = state;
} }
@@ -1715,7 +1726,7 @@ public sealed class GameService : IGameService
m_CampaignStateById.Clear(); m_CampaignStateById.Clear();
foreach (var campaignId in m_CampaignsById.Keys) foreach (var campaignId in m_CampaignsById.Keys)
m_CampaignStateById[campaignId] = new CampaignStateTracker(); m_CampaignStateById[campaignId] = new GameCampaignStateTracker();
foreach (var character in m_CharactersById.Values.Where(character => character.CampaignId.HasValue)) foreach (var character in m_CharactersById.Values.Where(character => character.CampaignId.HasValue))
AddCharacterStateLocked(character.CampaignId, character.Id); AddCharacterStateLocked(character.CampaignId, character.Id);
@@ -1723,91 +1734,14 @@ public sealed class GameService : IGameService
private void LoadStateFromDatabase() private void LoadStateFromDatabase()
{ {
using var db = m_DbContextFactory.CreateDbContext(); m_PersistenceService.LoadStateFromDatabase();
var users = db.Users.AsNoTracking().ToList();
var sessions = db.Sessions.AsNoTracking().ToList();
var campaigns = db.Campaigns.AsNoTracking().ToList();
var characters = db.Characters.AsNoTracking().ToList();
var skillGroups = db.SkillGroups.AsNoTracking().ToList();
var skills = db.Skills.AsNoTracking().ToList();
var logEntries = db.RollLogEntries.AsNoTracking().ToList().OrderBy(x => x.TimestampUtc).ThenBy(x => x.Id).ToList();
lock (m_Gate) lock (m_Gate)
{
m_UsersById.Clear();
m_UserIdsByUsername.Clear();
m_SessionsByToken.Clear();
m_CampaignsById.Clear();
m_CharactersById.Clear();
m_SkillGroupsById.Clear();
m_SkillsById.Clear();
m_RollLog.Clear();
foreach (var user in users)
{
var normalizedUsername = string.IsNullOrWhiteSpace(user.UsernameNormalized) ? NormalizeUsername(user.Username) : user.UsernameNormalized;
var storedUser = new UserAccount
{
Id = user.Id,
Username = user.Username,
UsernameNormalized = normalizedUsername,
PasswordHash = user.PasswordHash,
DisplayName = user.DisplayName,
Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : RoleSerializer.Serialize(RoleSerializer.Parse(user.Roles)),
ActiveCharacterId = user.ActiveCharacterId
};
m_UsersById[storedUser.Id] = storedUser;
m_UserIdsByUsername[normalizedUsername] = storedUser.Id;
}
foreach (var session in sessions)
{
if (m_UsersById.ContainsKey(session.UserId))
m_SessionsByToken[session.Token] = CloneSession(session);
}
foreach (var campaign in campaigns)
m_CampaignsById[campaign.Id] = CloneCampaign(campaign);
foreach (var character in characters)
m_CharactersById[character.Id] = CloneCharacter(character);
foreach (var skillGroup in skillGroups)
m_SkillGroupsById[skillGroup.Id] = CloneSkillGroup(skillGroup);
foreach (var skill in skills)
m_SkillsById[skill.Id] = CloneSkill(skill);
m_RollLog.AddRange(logEntries.Select(CloneRollLogEntry));
RebuildCampaignStateLocked(); RebuildCampaignStateLocked();
}
} }
private void PersistStateLocked() private void PersistStateLocked()
{ {
using var db = m_DbContextFactory.CreateDbContext(); m_PersistenceService.PersistStateLocked();
using var transaction = db.Database.BeginTransaction();
db.RollLogEntries.ExecuteDelete();
db.Skills.ExecuteDelete();
db.SkillGroups.ExecuteDelete();
db.Characters.ExecuteDelete();
db.Campaigns.ExecuteDelete();
db.Sessions.ExecuteDelete();
db.Users.ExecuteDelete();
db.Users.AddRange(m_UsersById.Values.Select(CloneUser));
db.Sessions.AddRange(m_SessionsByToken.Values.Select(CloneSession));
db.Campaigns.AddRange(m_CampaignsById.Values.Select(CloneCampaign));
db.Characters.AddRange(m_CharactersById.Values.Select(CloneCharacter));
db.SkillGroups.AddRange(m_SkillGroupsById.Values.Select(CloneSkillGroup));
db.Skills.AddRange(m_SkillsById.Values.Select(CloneSkill));
db.RollLogEntries.AddRange(m_RollLog.Select(CloneRollLogEntry));
db.SaveChanges();
transaction.Commit();
} }
private static string NormalizeUsername(string username) private static string NormalizeUsername(string username)
@@ -1823,124 +1757,24 @@ public sealed class GameService : IGameService
return Math.Clamp(limit.Value, 1, CampaignLogLivePageSize); return Math.Clamp(limit.Value, 1, CampaignLogLivePageSize);
} }
private static UserAccount CloneUser(UserAccount user)
{
return new()
{
Id = user.Id,
Username = user.Username,
UsernameNormalized = user.UsernameNormalized,
PasswordHash = user.PasswordHash,
DisplayName = user.DisplayName,
Roles = user.Roles,
ActiveCharacterId = user.ActiveCharacterId
};
}
private static UserSession CloneSession(UserSession session)
{
return new()
{
Token = session.Token,
UserId = session.UserId,
CreatedAtUtc = session.CreatedAtUtc
};
}
private static Campaign CloneCampaign(Campaign campaign)
{
return new()
{
Id = campaign.Id,
GmUserId = campaign.GmUserId,
Name = campaign.Name,
Ruleset = campaign.Ruleset,
Version = campaign.Version
};
}
private static Character CloneCharacter(Character character)
{
return new()
{
Id = character.Id,
OwnerUserId = character.OwnerUserId,
CampaignId = character.CampaignId,
Name = character.Name
};
}
private static Skill CloneSkill(Skill skill)
{
return new()
{
Id = skill.Id,
CharacterId = skill.CharacterId,
SkillGroupId = skill.SkillGroupId,
Name = skill.Name,
DiceRollDefinition = skill.DiceRollDefinition,
WildDice = skill.WildDice,
AllowFumble = skill.AllowFumble,
FumbleRange = skill.FumbleRange
};
}
private static SkillGroup CloneSkillGroup(SkillGroup skillGroup)
{
return new()
{
Id = skillGroup.Id,
CharacterId = skillGroup.CharacterId,
Name = skillGroup.Name,
DiceRollDefinition = skillGroup.DiceRollDefinition,
WildDice = skillGroup.WildDice,
AllowFumble = skillGroup.AllowFumble,
FumbleRange = skillGroup.FumbleRange
};
}
private static RollLogEntry CloneRollLogEntry(RollLogEntry entry)
{
return new()
{
Id = entry.Id,
CampaignId = entry.CampaignId,
CharacterId = entry.CharacterId,
SkillId = entry.SkillId,
RollerUserId = entry.RollerUserId,
Visibility = entry.Visibility,
Result = entry.Result,
Breakdown = entry.Breakdown,
Dice = entry.Dice,
TimestampUtc = entry.TimestampUtc
};
}
private const int CampaignLogHistoryWindowSize = 100; private const int CampaignLogHistoryWindowSize = 100;
private const int CampaignLogLivePageSize = 25; private const int CampaignLogLivePageSize = 25;
private const string CustomRollBreakdownSeparator = " => "; private const string CustomRollBreakdownSeparator = " => ";
private static readonly Guid CustomRollSkillId = Guid.Empty; private static readonly Guid CustomRollSkillId = Guid.Empty;
private const string CustomRollLabel = "Custom roll"; private const string CustomRollLabel = "Custom roll";
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, CampaignStateTracker> m_CampaignStateById = []; private readonly Dictionary<Guid, GameCampaignStateTracker> m_CampaignStateById;
private readonly Dictionary<Guid, Character> m_CharactersById = []; private readonly Dictionary<Guid, Character> m_CharactersById;
private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory;
private readonly IDiceRoller m_DiceRoller; private readonly IDiceRoller m_DiceRoller;
private readonly object m_Gate = new(); private readonly object m_Gate;
private readonly IPasswordHasher<UserAccount> m_PasswordHasher; private readonly IPasswordHasher<UserAccount> m_PasswordHasher;
private readonly List<RollLogEntry> m_RollLog = []; private readonly GamePersistenceService m_PersistenceService;
private readonly Dictionary<string, UserSession> m_SessionsByToken = new(StringComparer.Ordinal); private readonly List<RollLogEntry> m_RollLog;
private readonly Dictionary<Guid, SkillGroup> m_SkillGroupsById = []; private readonly Dictionary<string, UserSession> m_SessionsByToken;
private readonly Dictionary<Guid, Skill> m_SkillsById = []; private readonly Dictionary<Guid, SkillGroup> m_SkillGroupsById;
private readonly Dictionary<string, Guid> m_UserIdsByUsername = new(StringComparer.Ordinal); private readonly Dictionary<Guid, Skill> m_SkillsById;
private readonly Dictionary<Guid, UserAccount> m_UsersById = []; private readonly GameStateStore m_StateStore;
private readonly Dictionary<string, Guid> m_UserIdsByUsername;
private sealed class CampaignStateTracker private readonly Dictionary<Guid, UserAccount> m_UsersById;
{
public long TotalVersion { get; set; } = 1;
public long RosterVersion { get; set; } = 1;
public long LogVersion { get; set; } = 1;
public Dictionary<Guid, long> CharacterVersions { get; } = [];
}
} }

View File

@@ -0,0 +1,99 @@
using RpgRoller.Domain;
namespace RpgRoller.Services;
public static class GameStateCloneFactory
{
public static UserAccount CloneUser(UserAccount user)
{
return new()
{
Id = user.Id,
Username = user.Username,
UsernameNormalized = user.UsernameNormalized,
PasswordHash = user.PasswordHash,
DisplayName = user.DisplayName,
Roles = user.Roles,
ActiveCharacterId = user.ActiveCharacterId
};
}
public static UserSession CloneSession(UserSession session)
{
return new()
{
Token = session.Token,
UserId = session.UserId,
CreatedAtUtc = session.CreatedAtUtc
};
}
public static Campaign CloneCampaign(Campaign campaign)
{
return new()
{
Id = campaign.Id,
GmUserId = campaign.GmUserId,
Name = campaign.Name,
Ruleset = campaign.Ruleset,
Version = campaign.Version
};
}
public static Character CloneCharacter(Character character)
{
return new()
{
Id = character.Id,
OwnerUserId = character.OwnerUserId,
CampaignId = character.CampaignId,
Name = character.Name
};
}
public static Skill CloneSkill(Skill skill)
{
return new()
{
Id = skill.Id,
CharacterId = skill.CharacterId,
SkillGroupId = skill.SkillGroupId,
Name = skill.Name,
DiceRollDefinition = skill.DiceRollDefinition,
WildDice = skill.WildDice,
AllowFumble = skill.AllowFumble,
FumbleRange = skill.FumbleRange
};
}
public static SkillGroup CloneSkillGroup(SkillGroup skillGroup)
{
return new()
{
Id = skillGroup.Id,
CharacterId = skillGroup.CharacterId,
Name = skillGroup.Name,
DiceRollDefinition = skillGroup.DiceRollDefinition,
WildDice = skillGroup.WildDice,
AllowFumble = skillGroup.AllowFumble,
FumbleRange = skillGroup.FumbleRange
};
}
public static RollLogEntry CloneRollLogEntry(RollLogEntry entry)
{
return new()
{
Id = entry.Id,
CampaignId = entry.CampaignId,
CharacterId = entry.CharacterId,
SkillId = entry.SkillId,
RollerUserId = entry.RollerUserId,
Visibility = entry.Visibility,
Result = entry.Result,
Breakdown = entry.Breakdown,
Dice = entry.Dice,
TimestampUtc = entry.TimestampUtc
};
}
}

View File

@@ -0,0 +1,25 @@
using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class GameStateStore
{
public object Gate { get; } = new();
public Dictionary<Guid, Campaign> CampaignsById { get; } = [];
public Dictionary<Guid, GameCampaignStateTracker> CampaignStateById { get; } = [];
public Dictionary<Guid, Character> CharactersById { get; } = [];
public List<RollLogEntry> RollLog { get; } = [];
public Dictionary<string, UserSession> SessionsByToken { get; } = new(StringComparer.Ordinal);
public Dictionary<Guid, SkillGroup> SkillGroupsById { get; } = [];
public Dictionary<Guid, Skill> SkillsById { get; } = [];
public Dictionary<string, Guid> UserIdsByUsername { get; } = new(StringComparer.Ordinal);
public Dictionary<Guid, UserAccount> UsersById { get; } = [];
}
public sealed class GameCampaignStateTracker
{
public long TotalVersion { get; set; } = 1;
public long RosterVersion { get; set; } = 1;
public long LogVersion { get; set; } = 1;
public Dictionary<Guid, long> CharacterVersions { get; } = [];
}